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
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>