From a47b6895c2d507828c3ecd027501d0cbe1536443 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 15:16:40 +0200 Subject: [PATCH] feat: warn before leaving a form with unsaved changes Drag-dropping page images then clicking a nav link silently discarded the whole form. Add a reusable guardUnsavedChanges helper (SvelteKit beforeNavigate confirm + native beforeunload) and wire it into the manga upload, manga edit, and chapter upload forms. The dirty check returns false while submitting so the post-save redirect isn't prompted. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/e2e/unsaved-guard.spec.ts | 59 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/lib/unsaved-guard.ts | 36 +++++++++++ .../src/routes/manga/[id]/edit/+page.svelte | 20 +++++++ .../manga/[id]/upload-chapter/+page.svelte | 11 ++++ frontend/src/routes/upload/+page.svelte | 18 ++++++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/unsaved-guard.spec.ts create mode 100644 frontend/src/lib/unsaved-guard.ts diff --git a/frontend/e2e/unsaved-guard.spec.ts b/frontend/e2e/unsaved-guard.spec.ts new file mode 100644 index 0000000..9a35ef8 --- /dev/null +++ b/frontend/e2e/unsaved-guard.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, type Page } from './fixtures'; + +// Leaving a form with unsaved work (e.g. a half-filled upload) must prompt so a +// misclicked nav link doesn't silently discard everything. + +const DESKTOP = { width: 1280, height: 720 } as const; + +async function authed(page: Page) { + await page.route('**/api/v1/auth/config', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + user: { id: 'u1', username: 'uploader', created_at: '2026-01-01T00:00:00Z', is_admin: false } + }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/genres', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + ); + await page.route('**/api/v1/me/bookmarks*', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + }) + ); +} + +test('upload form prompts before navigating away with unsaved changes', async ({ page }) => { + await authed(page); + await page.setViewportSize(DESKTOP); + await page.goto('/upload'); + + // Make the form dirty. + await page.getByLabel(/Title/).fill('WIP Manga'); + + // Cancel the confirm → navigation is blocked, we stay put with our input. + let dialogSeen = false; + page.once('dialog', (d) => { + dialogSeen = true; + d.dismiss(); + }); + await page.getByRole('link', { name: 'Bookmarks' }).click(); + + await expect.poll(() => dialogSeen).toBe(true); + await expect(page).toHaveURL(/\/upload/); + await expect(page.getByLabel(/Title/)).toHaveValue('WIP Manga'); +}); diff --git a/frontend/package.json b/frontend/package.json index 9aa9fb7..328a465 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.127.2", + "version": "0.128.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/unsaved-guard.ts b/frontend/src/lib/unsaved-guard.ts new file mode 100644 index 0000000..43183c0 --- /dev/null +++ b/frontend/src/lib/unsaved-guard.ts @@ -0,0 +1,36 @@ +import { beforeNavigate } from '$app/navigation'; +import { onMount } from 'svelte'; + +/** + * Warn before leaving a page with unsaved form state. Call once from a + * component's script (during init). `isDirty` is read live on each navigation, + * so pass a closure over the form's reactive state — and have it return `false` + * once a save has succeeded (or is in flight) so the post-save redirect doesn't + * prompt. + * + * Covers both in-app navigation (SvelteKit `beforeNavigate`, with a confirm) and + * full-page unload / tab close / reload (the native `beforeunload` prompt). + */ +export function guardUnsavedChanges(isDirty: () => boolean): void { + beforeNavigate((nav) => { + // `leave` (tab close / reload) can't show a custom confirm here — the + // beforeunload handler below covers it. Only guard in-app navigations. + if (nav.type === 'leave') return; + if (!isDirty()) return; + if (!confirm('You have unsaved changes. Leave this page and discard them?')) { + nav.cancel(); + } + }); + + onMount(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty()) { + e.preventDefault(); + // Legacy browsers require returnValue to be set. + e.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }); +} diff --git a/frontend/src/routes/manga/[id]/edit/+page.svelte b/frontend/src/routes/manga/[id]/edit/+page.svelte index 19ce1b3..8da847d 100644 --- a/frontend/src/routes/manga/[id]/edit/+page.svelte +++ b/frontend/src/routes/manga/[id]/edit/+page.svelte @@ -1,6 +1,7 @@