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 { errCode } from '$lib/api/errors';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
let { playlist }: { playlist: Playlist } = $props();
|
let { playlist }: { playlist: Playlist } = $props();
|
||||||
|
|
||||||
@@ -18,16 +19,6 @@
|
|||||||
let starting = $state(false);
|
let starting = $state(false);
|
||||||
let menuOpen = $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) {
|
function toggleMenu(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -92,27 +83,17 @@
|
|||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
try {
|
try {
|
||||||
await refreshDiscover();
|
await refreshDiscover();
|
||||||
showToast('Discover refreshed.');
|
pushToast('Discover refreshed.');
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
|
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showToast(`Refresh failed: ${errCode(err)}`);
|
pushToast(`Refresh failed: ${errCode(err)}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={() => { menuOpen = false; }} />
|
<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">
|
<div class="card relative">
|
||||||
{#if isDiscover}
|
{#if isDiscover}
|
||||||
<div class="kebab-wrap absolute right-1 top-1 z-10">
|
<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 { user } from '$lib/auth/store.svelte';
|
||||||
import Shell from '$lib/components/Shell.svelte';
|
import Shell from '$lib/components/Shell.svelte';
|
||||||
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
||||||
|
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||||
import {
|
import {
|
||||||
registerAudioEl,
|
registerAudioEl,
|
||||||
reportTimeUpdate,
|
reportTimeUpdate,
|
||||||
@@ -111,3 +112,5 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|
||||||
|
<ToastHost />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { errCode, errMessage } from '$lib/api/errors';
|
import { errCode, errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import type {
|
import type {
|
||||||
AdminQuarantineRow,
|
AdminQuarantineRow,
|
||||||
LidarrRequest,
|
LidarrRequest,
|
||||||
@@ -63,16 +64,6 @@
|
|||||||
(quarantine.data ?? []).slice(0, PREVIEW_LIMIT)
|
(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 ----
|
// ---- Two-click destructive confirm for quarantine actions ----
|
||||||
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
|
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
|
||||||
// click within 3s fires the action; otherwise the key clears.
|
// click within 3s fires the action; otherwise the key clears.
|
||||||
@@ -132,7 +123,7 @@
|
|||||||
await approveRequest(r.id);
|
await approveRequest(r.id);
|
||||||
await invalidateRequests();
|
await invalidateRequests();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onReject(r: LidarrRequest) {
|
async function onReject(r: LidarrRequest) {
|
||||||
@@ -140,7 +131,7 @@
|
|||||||
await rejectRequest(r.id);
|
await rejectRequest(r.id);
|
||||||
await invalidateRequests();
|
await invalidateRequests();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +147,7 @@
|
|||||||
await resolveQuarantine(row.track_id);
|
await resolveQuarantine(row.track_id);
|
||||||
await invalidateQuarantine();
|
await invalidateQuarantine();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onDeleteFile(row: AdminQuarantineRow) {
|
async function onDeleteFile(row: AdminQuarantineRow) {
|
||||||
@@ -170,7 +161,7 @@
|
|||||||
await deleteQuarantineFile(row.track_id);
|
await deleteQuarantineFile(row.track_id);
|
||||||
await invalidateQuarantine();
|
await invalidateQuarantine();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onDeleteLidarr(row: AdminQuarantineRow) {
|
async function onDeleteLidarr(row: AdminQuarantineRow) {
|
||||||
@@ -184,7 +175,7 @@
|
|||||||
await deleteQuarantineViaLidarr(row.track_id);
|
await deleteQuarantineViaLidarr(row.track_id);
|
||||||
await invalidateQuarantine();
|
await invalidateQuarantine();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +277,7 @@
|
|||||||
await updateScanSchedule(patch);
|
await updateScanSchedule(patch);
|
||||||
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
|
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Schedule save failed: ${errCode(e)}`);
|
pushToast(`Schedule save failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
scheduleSaving = false;
|
scheduleSaving = false;
|
||||||
}
|
}
|
||||||
@@ -323,9 +314,9 @@
|
|||||||
researchSaving = true;
|
researchSaving = true;
|
||||||
try {
|
try {
|
||||||
await researchMissingArt();
|
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) {
|
} catch (e) {
|
||||||
showToast(`Re-search failed: ${errCode(e)}`);
|
pushToast(`Re-search failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
researchSaving = false;
|
researchSaving = false;
|
||||||
}
|
}
|
||||||
@@ -728,11 +719,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
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 -------------------------------------------------------------
|
// SMTP config -------------------------------------------------------------
|
||||||
|
|
||||||
const smtpStore = $derived(createSMTPConfigQuery());
|
const smtpStore = $derived(createSMTPConfigQuery());
|
||||||
@@ -272,11 +262,11 @@
|
|||||||
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
|
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
|
||||||
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
||||||
smtpPasswordInput = '';
|
smtpPasswordInput = '';
|
||||||
showToast('SMTP config saved.');
|
pushToast('SMTP config saved.');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
if (code === 'missing_fields') showToast('Host and from address are required when enabled.');
|
if (code === 'missing_fields') pushToast('Host and from address are required when enabled.', 'error');
|
||||||
else showToast(`Save failed: ${code}`);
|
else pushToast(`Save failed: ${code}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
smtpSaving = false;
|
smtpSaving = false;
|
||||||
}
|
}
|
||||||
@@ -286,14 +276,14 @@
|
|||||||
smtpTesting = true;
|
smtpTesting = true;
|
||||||
try {
|
try {
|
||||||
await testSMTPConfig();
|
await testSMTPConfig();
|
||||||
showToast('Test email sent. Check your inbox.');
|
pushToast('Test email sent. Check your inbox.');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
const message = (e as { message?: string })?.message;
|
const message = (e as { message?: string })?.message;
|
||||||
if (code === 'no_email_on_file') showToast('Set your email in /settings before testing.');
|
if (code === 'no_email_on_file') pushToast('Set your email in /settings before testing.', 'error');
|
||||||
else if (code === 'not_configured') showToast('Save the SMTP config first.');
|
else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error');
|
||||||
else if (code === 'send_failed') showToast(`Send failed: ${message || 'see server logs'}`);
|
else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error');
|
||||||
else showToast(`Test failed: ${code}`);
|
else pushToast(`Test failed: ${code}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
smtpTesting = false;
|
smtpTesting = false;
|
||||||
}
|
}
|
||||||
@@ -302,13 +292,6 @@
|
|||||||
|
|
||||||
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
<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">
|
<div class="space-y-6">
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import { playRadio } from '$lib/player/store.svelte';
|
import { playRadio } from '$lib/player/store.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
|
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
|
||||||
@@ -68,19 +69,6 @@
|
|||||||
// feedback if Lidarr can't be reached or the album lookup fails.
|
// feedback if Lidarr can't be reached or the album lookup fails.
|
||||||
let deleteLidarrError = $state<string | null>(null);
|
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() {
|
async function invalidate() {
|
||||||
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
|
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
|
||||||
}
|
}
|
||||||
@@ -90,7 +78,7 @@
|
|||||||
await resolveQuarantine(r.track_id);
|
await resolveQuarantine(r.track_id);
|
||||||
await invalidate();
|
await invalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +96,7 @@
|
|||||||
await deleteQuarantineFile(r.track_id);
|
await deleteQuarantineFile(r.track_id);
|
||||||
await invalidate();
|
await invalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +127,7 @@
|
|||||||
// alongside the album they were about to remove.
|
// alongside the album they were about to remove.
|
||||||
deleteLidarrError = msg;
|
deleteLidarrError = msg;
|
||||||
// Also surface as toast so it's visible after dismissing the modal.
|
// Also surface as toast so it's visible after dismissing the modal.
|
||||||
showToast(msg);
|
pushToast(msg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,17 +397,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</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>
|
<style>
|
||||||
.reason-pill {
|
.reason-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
rejectRequest
|
rejectRequest
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import StatusPill from '$lib/components/StatusPill.svelte';
|
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
|
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
|
||||||
@@ -56,20 +57,6 @@
|
|||||||
let rejectOpen = $state<string | null>(null);
|
let rejectOpen = $state<string | null>(null);
|
||||||
let rejectNotes = $state<string>('');
|
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) {
|
function fallbackIcon(kind: LidarrRequestKind) {
|
||||||
if (kind === 'artist') return Disc3;
|
if (kind === 'artist') return Disc3;
|
||||||
if (kind === 'album') return Album;
|
if (kind === 'album') return Album;
|
||||||
@@ -115,7 +102,7 @@
|
|||||||
}
|
}
|
||||||
await invalidate();
|
await invalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +142,7 @@
|
|||||||
await rejectRequest(r.id, notes ? notes : undefined);
|
await rejectRequest(r.id, notes ? notes : undefined);
|
||||||
await invalidate();
|
await invalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(errMessage(e));
|
pushToast(errMessage(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,17 +372,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
<style>
|
||||||
.kind-pill {
|
.kind-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
type CreateUserInput
|
type CreateUserInput
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
@@ -29,18 +30,8 @@
|
|||||||
const invitesQuery = $derived($invitesStore);
|
const invitesQuery = $derived($invitesStore);
|
||||||
const invites = $derived((invitesQuery.data ?? []) as AdminInvite[]);
|
const invites = $derived((invitesQuery.data ?? []) as AdminInvite[]);
|
||||||
|
|
||||||
let toast = $state<string | null>(null);
|
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
function showToast(msg: string) {
|
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
|
||||||
toast = msg;
|
|
||||||
toastTimer = setTimeout(() => {
|
|
||||||
toast = null;
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
let createForm = $state<CreateUserInput & { confirmPassword: string }>({
|
let createForm = $state<CreateUserInput & { confirmPassword: string }>({
|
||||||
@@ -62,13 +53,13 @@
|
|||||||
try {
|
try {
|
||||||
await updateUserAdmin(u.id, !u.is_admin);
|
await updateUserAdmin(u.id, !u.is_admin);
|
||||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
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) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
if (code === 'last_admin') {
|
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 {
|
} else {
|
||||||
showToast(`Action failed: ${code}`);
|
pushToast(`Action failed: ${code}`, 'error');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -78,7 +69,7 @@
|
|||||||
async function onSubmitCreate(e: SubmitEvent) {
|
async function onSubmitCreate(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (createForm.password !== createForm.confirmPassword) {
|
if (createForm.password !== createForm.confirmPassword) {
|
||||||
showToast('Passwords do not match.');
|
pushToast('Passwords do not match.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
@@ -92,9 +83,9 @@
|
|||||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
showCreateModal = false;
|
showCreateModal = false;
|
||||||
createForm = { username: '', password: '', confirmPassword: '', display_name: '', is_admin: false };
|
createForm = { username: '', password: '', confirmPassword: '', display_name: '', is_admin: false };
|
||||||
showToast('User created.');
|
pushToast('User created.');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
showToast(createUserErrorMessage(errCode(e)));
|
pushToast(createUserErrorMessage(errCode(e)), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -115,14 +106,14 @@
|
|||||||
try {
|
try {
|
||||||
await deleteUser(confirmDeleteTarget.id);
|
await deleteUser(confirmDeleteTarget.id);
|
||||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
showToast(`Deleted ${confirmDeleteTarget.username}.`);
|
pushToast(`Deleted ${confirmDeleteTarget.username}.`);
|
||||||
confirmDeleteTarget = null;
|
confirmDeleteTarget = null;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
if (code === 'last_admin') {
|
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 {
|
} else {
|
||||||
showToast(`Delete failed: ${code}`);
|
pushToast(`Delete failed: ${code}`, 'error');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -133,22 +124,22 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!resetPasswordTarget) return;
|
if (!resetPasswordTarget) return;
|
||||||
if (resetPasswordValue !== resetPasswordConfirm) {
|
if (resetPasswordValue !== resetPasswordConfirm) {
|
||||||
showToast('Passwords do not match.');
|
pushToast('Passwords do not match.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await resetUserPassword(resetPasswordTarget.id, resetPasswordValue);
|
await resetUserPassword(resetPasswordTarget.id, resetPasswordValue);
|
||||||
showToast(`Password reset for ${resetPasswordTarget.username}.`);
|
pushToast(`Password reset for ${resetPasswordTarget.username}.`);
|
||||||
resetPasswordTarget = null;
|
resetPasswordTarget = null;
|
||||||
resetPasswordValue = '';
|
resetPasswordValue = '';
|
||||||
resetPasswordConfirm = '';
|
resetPasswordConfirm = '';
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
if (code === 'password_too_short') {
|
if (code === 'password_too_short') {
|
||||||
showToast('Password must be at least 8 characters.');
|
pushToast('Password must be at least 8 characters.', 'error');
|
||||||
} else {
|
} else {
|
||||||
showToast(`Reset failed: ${code}`);
|
pushToast(`Reset failed: ${code}`, 'error');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -160,11 +151,11 @@
|
|||||||
try {
|
try {
|
||||||
await updateUserAutoApprove(u.id, !u.auto_approve_requests);
|
await updateUserAutoApprove(u.id, !u.auto_approve_requests);
|
||||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
showToast(u.auto_approve_requests
|
pushToast(u.auto_approve_requests
|
||||||
? `Auto-approve disabled for ${u.username}.`
|
? `Auto-approve disabled for ${u.username}.`
|
||||||
: `Auto-approve enabled for ${u.username}.`);
|
: `Auto-approve enabled for ${u.username}.`);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
showToast(`Toggle failed: ${errCode(e)}`);
|
pushToast(`Toggle failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -175,9 +166,9 @@
|
|||||||
try {
|
try {
|
||||||
await createInvite();
|
await createInvite();
|
||||||
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
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) {
|
} catch (e: unknown) {
|
||||||
showToast(`Generate failed: ${errCode(e)}`);
|
pushToast(`Generate failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -188,9 +179,9 @@
|
|||||||
try {
|
try {
|
||||||
await deleteInvite(invite.token);
|
await deleteInvite(invite.token);
|
||||||
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||||
showToast('Invite revoked.');
|
pushToast('Invite revoked.');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
showToast(`Revoke failed: ${errCode(e)}`);
|
pushToast(`Revoke failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -199,9 +190,9 @@
|
|||||||
async function copyToken(token: string) {
|
async function copyToken(token: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(token);
|
await navigator.clipboard.writeText(token);
|
||||||
showToast('Token copied to clipboard.');
|
pushToast('Token copied to clipboard.');
|
||||||
} catch {
|
} 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>
|
</section>
|
||||||
</div>
|
</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
|
<Modal
|
||||||
title="New user"
|
title="New user"
|
||||||
open={showCreateModal}
|
open={showCreateModal}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { errCode, errMessage } from '$lib/api/errors';
|
import { errCode, errMessage } from '$lib/api/errors';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
// SvelteKit types page.params.id as string | undefined (the route
|
// 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 ---
|
// --- Discover refresh ---
|
||||||
let refreshingDiscover = $state(false);
|
let refreshingDiscover = $state(false);
|
||||||
|
|
||||||
@@ -150,11 +141,11 @@
|
|||||||
refreshingDiscover = true;
|
refreshingDiscover = true;
|
||||||
try {
|
try {
|
||||||
await refreshDiscover();
|
await refreshDiscover();
|
||||||
showToast('Discover refreshed.');
|
pushToast('Discover refreshed.');
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
showToast(`Refresh failed: ${errCode(e)}`);
|
pushToast(`Refresh failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
refreshingDiscover = false;
|
refreshingDiscover = false;
|
||||||
}
|
}
|
||||||
@@ -165,16 +156,6 @@
|
|||||||
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
|
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
|
||||||
</svelte:head>
|
</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">
|
<div class="mx-auto max-w-4xl px-4 py-6">
|
||||||
{#if playlistQuery?.isPending}
|
{#if playlistQuery?.isPending}
|
||||||
<p class="text-text-muted">Loading…</p>
|
<p class="text-text-muted">Loading…</p>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
regenerateAPIToken
|
regenerateAPIToken
|
||||||
} from '$lib/api/me';
|
} from '$lib/api/me';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -40,17 +41,6 @@
|
|||||||
$enabledMutation.mutate(!$status.data.enabled);
|
$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 ------------------------------------------------------------
|
// Profile card ------------------------------------------------------------
|
||||||
|
|
||||||
let profileForm = $state<{ displayName: string; email: string }>({
|
let profileForm = $state<{ displayName: string; email: string }>({
|
||||||
@@ -66,12 +56,12 @@
|
|||||||
display_name: profileForm.displayName,
|
display_name: profileForm.displayName,
|
||||||
email: profileForm.email,
|
email: profileForm.email,
|
||||||
});
|
});
|
||||||
showToast('Profile saved.');
|
pushToast('Profile saved.');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
if (code === 'email_taken') showToast('That email is already in use.');
|
if (code === 'email_taken') pushToast('That email is already in use.', 'error');
|
||||||
else if (code === 'email_invalid') showToast('Email format is invalid.');
|
else if (code === 'email_invalid') pushToast('Email format is invalid.', 'error');
|
||||||
else showToast(`Save failed: ${code}`);
|
else pushToast(`Save failed: ${code}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
profileSaving = false;
|
profileSaving = false;
|
||||||
}
|
}
|
||||||
@@ -87,19 +77,19 @@
|
|||||||
async function onSavePassword(e: SubmitEvent) {
|
async function onSavePassword(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (passwordForm.new !== passwordForm.confirm) {
|
if (passwordForm.new !== passwordForm.confirm) {
|
||||||
showToast('New passwords do not match.');
|
pushToast('New passwords do not match.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
passwordSaving = true;
|
passwordSaving = true;
|
||||||
try {
|
try {
|
||||||
await changePassword(passwordForm.current, passwordForm.new);
|
await changePassword(passwordForm.current, passwordForm.new);
|
||||||
showToast('Password changed.');
|
pushToast('Password changed.');
|
||||||
passwordForm = { current: '', new: '', confirm: '' };
|
passwordForm = { current: '', new: '', confirm: '' };
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const code = errCode(e);
|
const code = errCode(e);
|
||||||
if (code === 'wrong_password') showToast('Current password is incorrect.');
|
if (code === 'wrong_password') pushToast('Current password is incorrect.', 'error');
|
||||||
else if (code === 'password_too_short') showToast('Password must be at least 8 characters.');
|
else if (code === 'password_too_short') pushToast('Password must be at least 8 characters.', 'error');
|
||||||
else showToast(`Change failed: ${code}`);
|
else pushToast(`Change failed: ${code}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
passwordSaving = false;
|
passwordSaving = false;
|
||||||
}
|
}
|
||||||
@@ -120,9 +110,9 @@
|
|||||||
if (!apiToken) return;
|
if (!apiToken) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(apiToken);
|
await navigator.clipboard.writeText(apiToken);
|
||||||
showToast('Token copied to clipboard.');
|
pushToast('Token copied to clipboard.');
|
||||||
} catch {
|
} catch {
|
||||||
showToast('Copy failed.');
|
pushToast('Copy failed.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +129,9 @@
|
|||||||
try {
|
try {
|
||||||
const r = await regenerateAPIToken();
|
const r = await regenerateAPIToken();
|
||||||
apiToken = r.api_token;
|
apiToken = r.api_token;
|
||||||
showToast('API token regenerated.');
|
pushToast('API token regenerated.');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
showToast(`Regenerate failed: ${errCode(e)}`);
|
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
tokenSaving = false;
|
tokenSaving = false;
|
||||||
}
|
}
|
||||||
@@ -150,13 +140,6 @@
|
|||||||
|
|
||||||
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
|
<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">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
<h1 class="text-2xl font-semibold">Settings</h1>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user