diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 97f0614..242da73 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.114.2" +version = "0.115.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6f24e15..dc5a7b1 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.114.2" +version = "0.115.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 1ac4f34..fe1587b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.114.2", + "version": "0.115.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte new file mode 100644 index 0000000..b36fcd0 --- /dev/null +++ b/frontend/src/lib/components/Toaster.svelte @@ -0,0 +1,104 @@ + + +
+ {#each toast.toasts as t (t.id)} +
+ {t.message} + +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/Toaster.svelte.test.ts b/frontend/src/lib/components/Toaster.svelte.test.ts new file mode 100644 index 0000000..d25d7b9 --- /dev/null +++ b/frontend/src/lib/components/Toaster.svelte.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import Toaster from './Toaster.svelte'; +import { toast } from '$lib/toast.svelte'; + +afterEach(() => { + cleanup(); + toast.clear(); +}); + +describe('Toaster', () => { + it('renders queued toasts with their message', async () => { + toast.info('Saved'); + render(Toaster); + expect(await screen.findByText('Saved')).toBeTruthy(); + }); + + it('gives errors an assertive alert role and others a status role', () => { + toast.error('Boom'); + toast.success('Yay'); + render(Toaster); + const toasts = screen.getAllByTestId('toast'); + const error = toasts.find((t) => t.dataset.kind === 'error')!; + const success = toasts.find((t) => t.dataset.kind === 'success')!; + expect(error.getAttribute('role')).toBe('alert'); + expect(success.getAttribute('role')).toBe('status'); + }); + + it('removes a toast when its dismiss button is clicked', async () => { + toast.error('Dismiss me'); + render(Toaster); + const btn = screen.getByLabelText('Dismiss notification'); + btn.click(); + await Promise.resolve(); + expect(screen.queryByText('Dismiss me')).toBeNull(); + expect(toast.toasts).toHaveLength(0); + }); +}); diff --git a/frontend/src/lib/toast.svelte.test.ts b/frontend/src/lib/toast.svelte.test.ts new file mode 100644 index 0000000..7b4a34a --- /dev/null +++ b/frontend/src/lib/toast.svelte.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { toast } from './toast.svelte'; + +afterEach(() => { + toast.clear(); + vi.useRealTimers(); +}); + +describe('toast store', () => { + it('shows a toast with the given message and kind, returning its id', () => { + const id = toast.show('hello', 'info'); + expect(toast.toasts).toHaveLength(1); + expect(toast.toasts[0]).toMatchObject({ id, message: 'hello', kind: 'info' }); + }); + + it('stacks multiple toasts in insertion order with distinct ids', () => { + const a = toast.info('first'); + const b = toast.error('second'); + expect(toast.toasts.map((t) => t.message)).toEqual(['first', 'second']); + expect(a).not.toBe(b); + }); + + it('exposes kind helpers', () => { + toast.error('boom'); + toast.success('yay'); + expect(toast.toasts.map((t) => t.kind)).toEqual(['error', 'success']); + }); + + it('dismisses a toast by id', () => { + const id = toast.info('bye'); + toast.dismiss(id); + expect(toast.toasts).toHaveLength(0); + }); + + it('auto-dismisses after the timeout', () => { + vi.useFakeTimers(); + toast.show('temp', 'info', 3000); + expect(toast.toasts).toHaveLength(1); + vi.advanceTimersByTime(3000); + expect(toast.toasts).toHaveLength(0); + }); + + it('does not auto-dismiss when the timeout is 0 (sticky)', () => { + vi.useFakeTimers(); + toast.show('sticky', 'error', 0); + vi.advanceTimersByTime(60_000); + expect(toast.toasts).toHaveLength(1); + }); + + it('clear() removes everything and cancels pending timers', () => { + vi.useFakeTimers(); + toast.info('a'); + toast.info('b'); + toast.clear(); + expect(toast.toasts).toHaveLength(0); + // A previously-scheduled auto-dismiss must not fire against a new toast. + const id = toast.info('c', 0); + vi.advanceTimersByTime(60_000); + expect(toast.toasts).toEqual([{ id, message: 'c', kind: 'info' }]); + }); +}); diff --git a/frontend/src/lib/toast.svelte.ts b/frontend/src/lib/toast.svelte.ts new file mode 100644 index 0000000..bd6dd61 --- /dev/null +++ b/frontend/src/lib/toast.svelte.ts @@ -0,0 +1,64 @@ +// App-wide transient notifications (toasts). +// +// A single client-side store; the in the root layout renders it. +// Mutated only from the browser (event handlers / catch blocks), so the +// module-level singleton can't leak across SSR requests — SSR renders an +// empty list and the client takes over after hydration. + +export type ToastKind = 'info' | 'success' | 'error'; +export type Toast = { id: number; kind: ToastKind; message: string }; + +const DEFAULT_TIMEOUT_MS = 5000; +// Errors linger longer — the user may need to read what failed. +const ERROR_TIMEOUT_MS = 8000; + +class ToastStore { + toasts = $state([]); + private nextId = 1; + private timers = new Map>(); + + /** + * Queue a toast. `timeoutMs = 0` makes it sticky (dismiss only via the + * close button / `dismiss`). Returns the id so callers can dismiss early. + */ + show(message: string, kind: ToastKind = 'info', timeoutMs: number = DEFAULT_TIMEOUT_MS): number { + const id = this.nextId++; + this.toasts = [...this.toasts, { id, kind, message }]; + if (timeoutMs > 0) { + this.timers.set( + id, + setTimeout(() => this.dismiss(id), timeoutMs) + ); + } + return id; + } + + error(message: string, timeoutMs: number = ERROR_TIMEOUT_MS): number { + return this.show(message, 'error', timeoutMs); + } + + success(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number { + return this.show(message, 'success', timeoutMs); + } + + info(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number { + return this.show(message, 'info', timeoutMs); + } + + dismiss(id: number): void { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + this.toasts = this.toasts.filter((t) => t.id !== id); + } + + clear(): void { + this.timers.forEach((t) => clearTimeout(t)); + this.timers.clear(); + this.toasts = []; + } +} + +export const toast = new ToastStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 62c6328..58947fa 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -10,6 +10,7 @@ import AppBar from '$lib/components/AppBar.svelte'; import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte'; import NavProgress from '$lib/components/NavProgress.svelte'; + import Toaster from '$lib/components/Toaster.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import Upload from '@lucide/svelte/icons/upload'; import UserCircle from '@lucide/svelte/icons/user-circle'; @@ -271,6 +272,8 @@ {/if} + +