refactor(web): pushToast store + ToastHost; 8 sites migrated (W3)
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
let { playlist }: { playlist: Playlist } = $props();
|
||||
|
||||
@@ -18,16 +19,6 @@
|
||||
let starting = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// --- Toast ---
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => { toast = null; }, 5000);
|
||||
}
|
||||
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -92,27 +83,17 @@
|
||||
menuOpen = false;
|
||||
try {
|
||||
await refreshDiscover();
|
||||
showToast('Discover refreshed.');
|
||||
pushToast('Discover refreshed.');
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (err: unknown) {
|
||||
showToast(`Refresh failed: ${errCode(err)}`);
|
||||
pushToast(`Refresh failed: ${errCode(err)}`, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => { menuOpen = false; }} />
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow-md"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card relative">
|
||||
{#if isDiscover}
|
||||
<div class="kebab-wrap absolute right-1 top-1 z-10">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
// Single global toast renderer. Mounted once in +layout.svelte; pages call
|
||||
// pushToast() from $lib/stores/toast.svelte. The {#key} re-mount on id
|
||||
// change is what makes screen readers re-announce a replacement toast.
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
</script>
|
||||
|
||||
{#if toast.value}
|
||||
{#key toast.value.id}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="toast"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border bg-surface px-4 py-3 text-sm shadow-lg"
|
||||
class:border-border={toast.value.variant === 'info'}
|
||||
class:text-text-primary={toast.value.variant === 'info'}
|
||||
class:border-error={toast.value.variant === 'error'}
|
||||
class:text-error={toast.value.variant === 'error'}
|
||||
>
|
||||
{toast.value.message}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Each test loads a fresh module so module-scope state (_toast, _timer,
|
||||
// _next) doesn't leak between cases. vi.useFakeTimers() controls the
|
||||
// auto-clear timeout deterministically; we never depend on real wall time.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('pushToast / clearToast', () => {
|
||||
test('pushToast sets value with default variant info', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('hello');
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
expect(mod.toast.value?.message).toBe('hello');
|
||||
expect(mod.toast.value?.variant).toBe('info');
|
||||
});
|
||||
|
||||
test('pushToast respects explicit variant error', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('boom', 'error');
|
||||
expect(mod.toast.value?.variant).toBe('error');
|
||||
expect(mod.toast.value?.message).toBe('boom');
|
||||
});
|
||||
|
||||
test('pushToast auto-clears after default 5000ms', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('temporary');
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('pushToast respects custom duration', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('quick', 'info', 1000);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('a second push replaces the first and only the second timer fires', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('first');
|
||||
vi.advanceTimersByTime(2000);
|
||||
mod.pushToast('second');
|
||||
expect(mod.toast.value?.message).toBe('second');
|
||||
// 4000ms after the second push, only 6000ms have elapsed total —
|
||||
// the first push's original 5000ms timer must NOT have fired
|
||||
// (which would have cleared the second toast).
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(mod.toast.value?.message).toBe('second');
|
||||
// Now run out the second's clock.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('clearToast resets value and timer', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('present');
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
mod.clearToast();
|
||||
expect(mod.toast.value).toBeNull();
|
||||
// Timer was cleared — advancing past the original duration must not
|
||||
// re-clear or otherwise touch state in a way that would surface here.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('each push increments the id (drives {#key} re-mount)', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('a');
|
||||
const firstId = mod.toast.value?.id;
|
||||
mod.pushToast('b');
|
||||
const secondId = mod.toast.value?.id;
|
||||
mod.pushToast('c');
|
||||
const thirdId = mod.toast.value?.id;
|
||||
expect(firstId).toBeDefined();
|
||||
expect(secondId).toBeDefined();
|
||||
expect(thirdId).toBeDefined();
|
||||
expect(secondId!).toBeGreaterThan(firstId!);
|
||||
expect(thirdId!).toBeGreaterThan(secondId!);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Shared toast store. Pages call `pushToast(message, variant?)`; a single
|
||||
// <ToastHost /> mounted in +layout.svelte renders the result. Replacing a
|
||||
// live toast cancels its timer so the new message gets the full duration,
|
||||
// and bumps `id` so the host's {#key} block re-mounts the node — important
|
||||
// for screen readers, which won't re-announce text changes inside an
|
||||
// already-live region.
|
||||
|
||||
type ToastVariant = 'info' | 'error';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
let _toast = $state<Toast | null>(null);
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _next = 0;
|
||||
|
||||
export const toast = {
|
||||
get value(): Toast | null {
|
||||
return _toast;
|
||||
}
|
||||
};
|
||||
|
||||
export function pushToast(
|
||||
message: string,
|
||||
variant: ToastVariant = 'info',
|
||||
durationMs = 5000
|
||||
): void {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
_toast = { id: ++_next, message, variant };
|
||||
_timer = setTimeout(() => {
|
||||
_toast = null;
|
||||
_timer = null;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
export function clearToast(): void {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
_timer = null;
|
||||
_toast = null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import Shell from '$lib/components/Shell.svelte';
|
||||
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
import {
|
||||
registerAudioEl,
|
||||
reportTimeUpdate,
|
||||
@@ -111,3 +112,5 @@
|
||||
{@render children()}
|
||||
{/if}
|
||||
</QueryClientProvider>
|
||||
|
||||
<ToastHost />
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { errCode, errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import type {
|
||||
AdminQuarantineRow,
|
||||
LidarrRequest,
|
||||
@@ -63,16 +64,6 @@
|
||||
(quarantine.data ?? []).slice(0, PREVIEW_LIMIT)
|
||||
);
|
||||
|
||||
// ---- Toast (shared) ----
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => { toast = null; }, 5000);
|
||||
}
|
||||
|
||||
// ---- Two-click destructive confirm for quarantine actions ----
|
||||
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
|
||||
// click within 3s fires the action; otherwise the key clears.
|
||||
@@ -132,7 +123,7 @@
|
||||
await approveRequest(r.id);
|
||||
await invalidateRequests();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
async function onReject(r: LidarrRequest) {
|
||||
@@ -140,7 +131,7 @@
|
||||
await rejectRequest(r.id);
|
||||
await invalidateRequests();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +147,7 @@
|
||||
await resolveQuarantine(row.track_id);
|
||||
await invalidateQuarantine();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
async function onDeleteFile(row: AdminQuarantineRow) {
|
||||
@@ -170,7 +161,7 @@
|
||||
await deleteQuarantineFile(row.track_id);
|
||||
await invalidateQuarantine();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
async function onDeleteLidarr(row: AdminQuarantineRow) {
|
||||
@@ -184,7 +175,7 @@
|
||||
await deleteQuarantineViaLidarr(row.track_id);
|
||||
await invalidateQuarantine();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +277,7 @@
|
||||
await updateScanSchedule(patch);
|
||||
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
|
||||
} catch (e) {
|
||||
showToast(`Schedule save failed: ${errCode(e)}`);
|
||||
pushToast(`Schedule save failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
scheduleSaving = false;
|
||||
}
|
||||
@@ -323,9 +314,9 @@
|
||||
researchSaving = true;
|
||||
try {
|
||||
await researchMissingArt();
|
||||
showToast('All previously-failed art will be re-attempted on the next scan.');
|
||||
pushToast('All previously-failed art will be re-attempted on the next scan.');
|
||||
} catch (e) {
|
||||
showToast(`Re-search failed: ${errCode(e)}`);
|
||||
pushToast(`Re-search failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
researchSaving = false;
|
||||
}
|
||||
@@ -728,11 +719,3 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="alert"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||
|
||||
@@ -235,17 +236,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Toast -------------------------------------------------------------------
|
||||
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast = msg;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toast = null; }, 4000);
|
||||
}
|
||||
|
||||
// SMTP config -------------------------------------------------------------
|
||||
|
||||
const smtpStore = $derived(createSMTPConfigQuery());
|
||||
@@ -272,11 +262,11 @@
|
||||
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
|
||||
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
||||
smtpPasswordInput = '';
|
||||
showToast('SMTP config saved.');
|
||||
pushToast('SMTP config saved.');
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'missing_fields') showToast('Host and from address are required when enabled.');
|
||||
else showToast(`Save failed: ${code}`);
|
||||
if (code === 'missing_fields') pushToast('Host and from address are required when enabled.', 'error');
|
||||
else pushToast(`Save failed: ${code}`, 'error');
|
||||
} finally {
|
||||
smtpSaving = false;
|
||||
}
|
||||
@@ -286,14 +276,14 @@
|
||||
smtpTesting = true;
|
||||
try {
|
||||
await testSMTPConfig();
|
||||
showToast('Test email sent. Check your inbox.');
|
||||
pushToast('Test email sent. Check your inbox.');
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
const message = (e as { message?: string })?.message;
|
||||
if (code === 'no_email_on_file') showToast('Set your email in /settings before testing.');
|
||||
else if (code === 'not_configured') showToast('Save the SMTP config first.');
|
||||
else if (code === 'send_failed') showToast(`Send failed: ${message || 'see server logs'}`);
|
||||
else showToast(`Test failed: ${code}`);
|
||||
if (code === 'no_email_on_file') pushToast('Set your email in /settings before testing.', 'error');
|
||||
else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error');
|
||||
else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error');
|
||||
else pushToast(`Test failed: ${code}`, 'error');
|
||||
} finally {
|
||||
smtpTesting = false;
|
||||
}
|
||||
@@ -302,13 +292,6 @@
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
||||
|
||||
{#if toast}
|
||||
<div data-testid="toast" role="status"
|
||||
class="fixed bottom-4 right-4 z-50 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow">
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import { playRadio } from '$lib/player/store.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
|
||||
@@ -68,19 +69,6 @@
|
||||
// feedback if Lidarr can't be reached or the album lookup fails.
|
||||
let deleteLidarrError = $state<string | null>(null);
|
||||
|
||||
// Toast surface — same pattern as /admin/requests for the lidarr-unreachable
|
||||
// and other error codes from /resolve and /delete-file.
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => {
|
||||
toast = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
|
||||
}
|
||||
@@ -90,7 +78,7 @@
|
||||
await resolveQuarantine(r.track_id);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +96,7 @@
|
||||
await deleteQuarantineFile(r.track_id);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +127,7 @@
|
||||
// alongside the album they were about to remove.
|
||||
deleteLidarrError = msg;
|
||||
// Also surface as toast so it's visible after dismissing the modal.
|
||||
showToast(msg);
|
||||
pushToast(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,17 +397,6 @@
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
|
||||
data-testid="toast"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.reason-pill {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
rejectRequest
|
||||
} from '$lib/api/admin';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
|
||||
@@ -56,20 +57,6 @@
|
||||
let rejectOpen = $state<string | null>(null);
|
||||
let rejectNotes = $state<string>('');
|
||||
|
||||
// Toast surface — single line, fixed bottom-right, auto-clears after 5s.
|
||||
// No third-party lib for v1; this is enough to surface the lidarr-unreachable
|
||||
// error per spec §7 without dragging in a toast framework.
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => {
|
||||
toast = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function fallbackIcon(kind: LidarrRequestKind) {
|
||||
if (kind === 'artist') return Disc3;
|
||||
if (kind === 'album') return Album;
|
||||
@@ -115,7 +102,7 @@
|
||||
}
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +142,7 @@
|
||||
await rejectRequest(r.id, notes ? notes : undefined);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
showToast(errMessage(e));
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,17 +372,6 @@
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
|
||||
data-testid="toast"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.kind-pill {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
type CreateUserInput
|
||||
} from '$lib/api/admin';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
const client = useQueryClient();
|
||||
@@ -29,18 +30,8 @@
|
||||
const invitesQuery = $derived($invitesStore);
|
||||
const invites = $derived((invitesQuery.data ?? []) as AdminInvite[]);
|
||||
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saving = $state(false);
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => {
|
||||
toast = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Modal state
|
||||
let showCreateModal = $state(false);
|
||||
let createForm = $state<CreateUserInput & { confirmPassword: string }>({
|
||||
@@ -62,13 +53,13 @@
|
||||
try {
|
||||
await updateUserAdmin(u.id, !u.is_admin);
|
||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
showToast(u.is_admin ? `${u.username} is no longer an admin.` : `${u.username} is now an admin.`);
|
||||
pushToast(u.is_admin ? `${u.username} is no longer an admin.` : `${u.username} is now an admin.`);
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'last_admin') {
|
||||
showToast(`Can't remove the last admin — promote someone else first.`);
|
||||
pushToast(`Can't remove the last admin — promote someone else first.`, 'error');
|
||||
} else {
|
||||
showToast(`Action failed: ${code}`);
|
||||
pushToast(`Action failed: ${code}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
@@ -78,7 +69,7 @@
|
||||
async function onSubmitCreate(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (createForm.password !== createForm.confirmPassword) {
|
||||
showToast('Passwords do not match.');
|
||||
pushToast('Passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
@@ -92,9 +83,9 @@
|
||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
showCreateModal = false;
|
||||
createForm = { username: '', password: '', confirmPassword: '', display_name: '', is_admin: false };
|
||||
showToast('User created.');
|
||||
pushToast('User created.');
|
||||
} catch (e: unknown) {
|
||||
showToast(createUserErrorMessage(errCode(e)));
|
||||
pushToast(createUserErrorMessage(errCode(e)), 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -115,14 +106,14 @@
|
||||
try {
|
||||
await deleteUser(confirmDeleteTarget.id);
|
||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
showToast(`Deleted ${confirmDeleteTarget.username}.`);
|
||||
pushToast(`Deleted ${confirmDeleteTarget.username}.`);
|
||||
confirmDeleteTarget = null;
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'last_admin') {
|
||||
showToast(`Can't delete the last admin — promote someone else first.`);
|
||||
pushToast(`Can't delete the last admin — promote someone else first.`, 'error');
|
||||
} else {
|
||||
showToast(`Delete failed: ${code}`);
|
||||
pushToast(`Delete failed: ${code}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
@@ -133,22 +124,22 @@
|
||||
e.preventDefault();
|
||||
if (!resetPasswordTarget) return;
|
||||
if (resetPasswordValue !== resetPasswordConfirm) {
|
||||
showToast('Passwords do not match.');
|
||||
pushToast('Passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
await resetUserPassword(resetPasswordTarget.id, resetPasswordValue);
|
||||
showToast(`Password reset for ${resetPasswordTarget.username}.`);
|
||||
pushToast(`Password reset for ${resetPasswordTarget.username}.`);
|
||||
resetPasswordTarget = null;
|
||||
resetPasswordValue = '';
|
||||
resetPasswordConfirm = '';
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'password_too_short') {
|
||||
showToast('Password must be at least 8 characters.');
|
||||
pushToast('Password must be at least 8 characters.', 'error');
|
||||
} else {
|
||||
showToast(`Reset failed: ${code}`);
|
||||
pushToast(`Reset failed: ${code}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
@@ -160,11 +151,11 @@
|
||||
try {
|
||||
await updateUserAutoApprove(u.id, !u.auto_approve_requests);
|
||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
showToast(u.auto_approve_requests
|
||||
pushToast(u.auto_approve_requests
|
||||
? `Auto-approve disabled for ${u.username}.`
|
||||
: `Auto-approve enabled for ${u.username}.`);
|
||||
} catch (e: unknown) {
|
||||
showToast(`Toggle failed: ${errCode(e)}`);
|
||||
pushToast(`Toggle failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -175,9 +166,9 @@
|
||||
try {
|
||||
await createInvite();
|
||||
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
showToast('Invite generated. Copy the token from the list below.');
|
||||
pushToast('Invite generated. Copy the token from the list below.');
|
||||
} catch (e: unknown) {
|
||||
showToast(`Generate failed: ${errCode(e)}`);
|
||||
pushToast(`Generate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -188,9 +179,9 @@
|
||||
try {
|
||||
await deleteInvite(invite.token);
|
||||
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
showToast('Invite revoked.');
|
||||
pushToast('Invite revoked.');
|
||||
} catch (e: unknown) {
|
||||
showToast(`Revoke failed: ${errCode(e)}`);
|
||||
pushToast(`Revoke failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -199,9 +190,9 @@
|
||||
async function copyToken(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
showToast('Token copied to clipboard.');
|
||||
pushToast('Token copied to clipboard.');
|
||||
} catch {
|
||||
showToast('Copy failed — select the token and copy manually.');
|
||||
pushToast('Copy failed — select the token and copy manually.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,17 +353,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
|
||||
data-testid="toast"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
title="New user"
|
||||
open={showCreateModal}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { errCode, errMessage } from '$lib/api/errors';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
// SvelteKit types page.params.id as string | undefined (the route
|
||||
@@ -133,16 +134,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toast (detail page) ---
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => { toast = null; }, 5000);
|
||||
}
|
||||
|
||||
// --- Discover refresh ---
|
||||
let refreshingDiscover = $state(false);
|
||||
|
||||
@@ -150,11 +141,11 @@
|
||||
refreshingDiscover = true;
|
||||
try {
|
||||
await refreshDiscover();
|
||||
showToast('Discover refreshed.');
|
||||
pushToast('Discover refreshed.');
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (e: unknown) {
|
||||
showToast(`Refresh failed: ${errCode(e)}`);
|
||||
pushToast(`Refresh failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
refreshingDiscover = false;
|
||||
}
|
||||
@@ -165,16 +156,6 @@
|
||||
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow-md"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-6">
|
||||
{#if playlistQuery?.isPending}
|
||||
<p class="text-text-muted">Loading…</p>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
regenerateAPIToken
|
||||
} from '$lib/api/me';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -40,17 +41,6 @@
|
||||
$enabledMutation.mutate(!$status.data.enabled);
|
||||
}
|
||||
|
||||
// Toast -------------------------------------------------------------------
|
||||
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast = msg;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toast = null; }, 4000);
|
||||
}
|
||||
|
||||
// Profile card ------------------------------------------------------------
|
||||
|
||||
let profileForm = $state<{ displayName: string; email: string }>({
|
||||
@@ -66,12 +56,12 @@
|
||||
display_name: profileForm.displayName,
|
||||
email: profileForm.email,
|
||||
});
|
||||
showToast('Profile saved.');
|
||||
pushToast('Profile saved.');
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'email_taken') showToast('That email is already in use.');
|
||||
else if (code === 'email_invalid') showToast('Email format is invalid.');
|
||||
else showToast(`Save failed: ${code}`);
|
||||
if (code === 'email_taken') pushToast('That email is already in use.', 'error');
|
||||
else if (code === 'email_invalid') pushToast('Email format is invalid.', 'error');
|
||||
else pushToast(`Save failed: ${code}`, 'error');
|
||||
} finally {
|
||||
profileSaving = false;
|
||||
}
|
||||
@@ -87,19 +77,19 @@
|
||||
async function onSavePassword(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (passwordForm.new !== passwordForm.confirm) {
|
||||
showToast('New passwords do not match.');
|
||||
pushToast('New passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
passwordSaving = true;
|
||||
try {
|
||||
await changePassword(passwordForm.current, passwordForm.new);
|
||||
showToast('Password changed.');
|
||||
pushToast('Password changed.');
|
||||
passwordForm = { current: '', new: '', confirm: '' };
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'wrong_password') showToast('Current password is incorrect.');
|
||||
else if (code === 'password_too_short') showToast('Password must be at least 8 characters.');
|
||||
else showToast(`Change failed: ${code}`);
|
||||
if (code === 'wrong_password') pushToast('Current password is incorrect.', 'error');
|
||||
else if (code === 'password_too_short') pushToast('Password must be at least 8 characters.', 'error');
|
||||
else pushToast(`Change failed: ${code}`, 'error');
|
||||
} finally {
|
||||
passwordSaving = false;
|
||||
}
|
||||
@@ -120,9 +110,9 @@
|
||||
if (!apiToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiToken);
|
||||
showToast('Token copied to clipboard.');
|
||||
pushToast('Token copied to clipboard.');
|
||||
} catch {
|
||||
showToast('Copy failed.');
|
||||
pushToast('Copy failed.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +129,9 @@
|
||||
try {
|
||||
const r = await regenerateAPIToken();
|
||||
apiToken = r.api_token;
|
||||
showToast('API token regenerated.');
|
||||
pushToast('API token regenerated.');
|
||||
} catch (e: unknown) {
|
||||
showToast(`Regenerate failed: ${errCode(e)}`);
|
||||
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
tokenSaving = false;
|
||||
}
|
||||
@@ -150,13 +140,6 @@
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
|
||||
|
||||
{#if toast}
|
||||
<div data-testid="toast" role="status"
|
||||
class="fixed bottom-4 right-4 z-50 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow">
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user