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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 15:16:40 +02:00
parent 3ca05dcb58
commit a47b6895c2
6 changed files with 145 additions and 1 deletions

View File

@@ -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');
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.127.2",
"version": "0.128.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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);
});
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { guardUnsavedChanges } from '$lib/unsaved-guard';
import {
deleteMangaCover,
updateManga,
@@ -49,6 +50,25 @@
mangaTitle.trim().length > 0 && !coverError && !submitting
);
// Warn before leaving with unsaved edits. `submitting` stays true through
// the post-save redirect, so that navigation isn't prompted.
guardUnsavedChanges(
() =>
!submitting &&
(mangaTitle !== data.manga.title ||
mangaStatus !== data.manga.status ||
mangaDescription !== (data.manga.description ?? '') ||
JSON.stringify(mangaAuthors) !==
JSON.stringify(data.manga.authors.map((a) => a.name)) ||
JSON.stringify(mangaAltTitles) !== JSON.stringify([...data.manga.alt_titles]) ||
JSON.stringify(mangaGenreIds) !==
JSON.stringify(data.manga.genres.map((g) => g.id)) ||
coverFile != null ||
pendingCoverRemoval ||
authorDraft.trim() !== '' ||
altTitleDraft.trim() !== '')
);
function addAuthor() {
const name = authorDraft.trim();
if (!name) return;

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { guardUnsavedChanges } from '$lib/unsaved-guard';
import { createChapter } from '$lib/api/chapters';
import { session } from '$lib/session.svelte';
import ChapterPagesEditor, {
@@ -19,6 +20,16 @@
let submitting = $state(false);
let error: string | null = $state(null);
// Warn before leaving with staged pages / edits. `submitting` stays true
// through the post-save redirect, so that navigation isn't prompted.
guardUnsavedChanges(
() =>
!submitting &&
(pages.length > 0 ||
title.trim() !== '' ||
(number != null && number !== data.defaultNumber))
);
const allPagesValid = $derived(pages.every((p) => !p.error));
const canSubmit = $derived(
Boolean(session.user) &&

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api/client';
import { guardUnsavedChanges } from '$lib/unsaved-guard';
import { createManga, type MangaStatus } from '$lib/api/mangas';
import { createChapter } from '$lib/api/chapters';
import { session } from '$lib/session.svelte';
@@ -43,6 +44,23 @@
let mangaError = $state<string | null>(null);
let success = $state<string | null>(null);
// Warn before navigating away with an in-progress upload. Suppressed while
// submitting and after a successful save (which redirects).
guardUnsavedChanges(
() =>
!success &&
!submitting &&
(mangaTitle.trim() !== '' ||
mangaDescription.trim() !== '' ||
mangaAuthors.length > 0 ||
mangaAltTitles.length > 0 ||
mangaGenreIds.length > 0 ||
coverFile != null ||
stagedChapters.length > 0 ||
authorDraft.trim() !== '' ||
altTitleDraft.trim() !== '')
);
const allChapterPagesValid = $derived(
stagedChapters.every((c) => c.pages.every((p) => !p.error))
);