refactor(web): pushToast store + ToastHost; 8 sites migrated (W3)

This commit is contained in:
2026-05-08 05:45:34 -04:00
parent 970752a153
commit 617477b702
12 changed files with 229 additions and 223 deletions
+3 -22
View File
@@ -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">
+23
View File
@@ -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}
+93
View File
@@ -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!);
});
});
+43
View File
@@ -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;
}
+3
View File
@@ -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 />
+9 -26
View File
@@ -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}
+9 -26
View File
@@ -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>
+4 -27
View File
@@ -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;
+3 -27
View File
@@ -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;
+22 -42
View File
@@ -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}
+3 -22
View File
@@ -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>
+14 -31
View File
@@ -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>