diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index c14c289d..665124a2 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -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(null); - let toastTimer: ReturnType | 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'); } } { menuOpen = false; }} /> -{#if toast} -
- {toast} -
-{/if} -
{#if isDiscover}
diff --git a/web/src/lib/components/ToastHost.svelte b/web/src/lib/components/ToastHost.svelte new file mode 100644 index 00000000..3144510c --- /dev/null +++ b/web/src/lib/components/ToastHost.svelte @@ -0,0 +1,23 @@ + + +{#if toast.value} + {#key toast.value.id} +
+ {toast.value.message} +
+ {/key} +{/if} diff --git a/web/src/lib/stores/toast.svelte.test.ts b/web/src/lib/stores/toast.svelte.test.ts new file mode 100644 index 00000000..b96b5062 --- /dev/null +++ b/web/src/lib/stores/toast.svelte.test.ts @@ -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!); + }); +}); diff --git a/web/src/lib/stores/toast.svelte.ts b/web/src/lib/stores/toast.svelte.ts new file mode 100644 index 00000000..6a324c8a --- /dev/null +++ b/web/src/lib/stores/toast.svelte.ts @@ -0,0 +1,43 @@ +// Shared toast store. Pages call `pushToast(message, variant?)`; a single +// 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(null); +let _timer: ReturnType | 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; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 89c49961..7d397225 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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} + + diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 46175da9..82328ab9 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -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(null); - let toastTimer: ReturnType | 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 @@
-{#if toast} - -{/if} diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 09cfcd58..8512c15f 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -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(null); - let toastTimer: ReturnType | 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 @@ {pageTitle('Admin · Integrations')} -{#if toast} -
- {toast} -
-{/if} -

Integrations

diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte index 34827da1..b2ac18f4 100644 --- a/web/src/routes/admin/quarantine/+page.svelte +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -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(null); - // Toast surface — same pattern as /admin/requests for the lidarr-unreachable - // and other error codes from /resolve and /delete-file. - let toast = $state(null); - let toastTimer: ReturnType | 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} -{#if toast} -
- {toast} -
-{/if} -