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