diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte
index 5c418a1e..8060776f 100644
--- a/frontend/src/lib/geschichte/JourneyEditor.svelte
+++ b/frontend/src/lib/geschichte/JourneyEditor.svelte
@@ -11,6 +11,7 @@ import type { DocumentOption } from '$lib/document/documentTypeahead';
import GeschichteSidebar from './GeschichteSidebar.svelte';
import JourneyItemRow from './JourneyItemRow.svelte';
import JourneyAddBar from './JourneyAddBar.svelte';
+import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
@@ -224,19 +225,27 @@ async function handleMoveDown(index: number) {
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
- await onSubmit({
- title: title.trim(),
- body,
- status: nextStatus,
- personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
- });
- unsaved.clearOnSuccess();
+ try {
+ await onSubmit({
+ title: title.trim(),
+ body,
+ status: nextStatus,
+ personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
+ });
+ unsaved.clearOnSuccess();
+ } catch {
+ // onSubmit signalled failure — keep dirty flag so the banner stays
+ }
}
diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
index db181eca..cd69e985 100644
--- a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
+++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
@@ -4,6 +4,9 @@ import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyEditor from './JourneyEditor.svelte';
+vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
+import { beforeNavigate } from '$app/navigation';
+
const docSummary = (id: string, title: string) => ({
id,
title,
@@ -612,6 +615,69 @@ describe('JourneyEditor — duplicate document aria-disabled', () => {
});
});
+describe('JourneyEditor — unsaved warning banner', () => {
+ function triggerNavigationAttempt() {
+ const calls = vi.mocked(beforeNavigate).mock.calls;
+ if (calls.length === 0) return;
+ const [callback] = calls[calls.length - 1];
+ const cancel = vi.fn();
+ (callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
+ cancel,
+ to: { url: new URL('http://localhost/geschichten') }
+ });
+ return cancel;
+ }
+
+ it('banner is absent before any edit or navigation attempt', async () => {
+ render(JourneyEditor, defaultProps());
+ expect(document.querySelector('[class*="amber"]')).toBeNull();
+ });
+
+ it('banner appears when dirty and a navigation is attempted', async () => {
+ render(JourneyEditor, defaultProps());
+
+ // Mark dirty by editing the title
+ const titleInput = page.getByPlaceholder(/Titel/);
+ await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
+
+ // Simulate the user trying to navigate away
+ const cancel = triggerNavigationAttempt();
+ expect(cancel).toHaveBeenCalled();
+
+ await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
+ });
+
+ it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
+ const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
+ render(
+ JourneyEditor,
+ defaultProps({
+ onSubmit,
+ geschichte: makeGeschichte({
+ title: 'Titel',
+ items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
+ })
+ })
+ );
+
+ // Mark dirty
+ const titleInput = page.getByPlaceholder(/Titel/);
+ await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
+
+ // Trigger navigation → banner appears
+ triggerNavigationAttempt();
+ await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
+
+ // Attempt save — onSubmit throws
+ await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
+
+ // Banner must still be visible (isDirty was not cleared)
+ await vi.waitFor(() => {
+ expect(document.querySelector('[class*="amber"]')).toBeTruthy();
+ });
+ });
+});
+
describe('JourneyEditor — person chips from GeschichteView', () => {
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
render(
diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte
index 472b5238..3b77c83c 100644
--- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte
+++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte
@@ -32,7 +32,7 @@ async function handleSubmit(payload: {
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
- return;
+ throw new Error('save failed');
}
goto(`/geschichten/${data.geschichte.id}`);
} finally {