- {g.title} -
++ {g.title} +
+ {#if isJourney} + + {m.journey_badge_detail()} + + {/if} +- {authorName()} + {authorName} {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml
index a73ecc4a..62cec50f 100644
--- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml
+++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml
@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
- Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
- Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
+ Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
+ Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (rich text editor, person linking, POST /api/geschichten) or JOURNEY placeholder (editor deferred to #753). Edit: PUT /api/geschichten/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
-Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
-Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
+Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
+Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 928460c3..99c9c1b7 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -1159,5 +1159,20 @@
"themen_alle": "Alle Themen",
"themen_leer": "Noch keine Themen vergeben.",
"themen_weitere": "+ {count} weitere",
- "themen_dokumente": "{count} Dokumente"
+ "themen_dokumente": "{count} Dokumente",
+ "journey_badge_list": "REISE",
+ "journey_badge_detail": "LESEREISE",
+ "journey_selector_question": "Was möchtest du erstellen?",
+ "journey_selector_story_title": "Geschichte",
+ "journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
+ "journey_selector_journey_title": "Lesereise",
+ "journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
+ "journey_selector_next_btn": "Weiter",
+ "journey_placeholder_back": "andere Auswahl",
+ "journey_placeholder_heading": "Lesereise-Editor folgt in #753",
+ "journey_item_open_aria": "Brief vom {date} öffnen",
+ "journey_item_open_aria_undated": "Brief öffnen",
+ "journey_empty_state": "Diese Lesereise ist noch leer.",
+ "journey_interlude_aria_label": "Kuratorennotiz",
+ "journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren."
}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index e10ef624..0c4686a4 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1159,5 +1159,20 @@
"themen_alle": "All Topics",
"themen_leer": "No topics assigned yet.",
"themen_weitere": "+ {count} more",
- "themen_dokumente": "{count} documents"
+ "themen_dokumente": "{count} documents",
+ "journey_badge_list": "JOURNEY",
+ "journey_badge_detail": "READING JOURNEY",
+ "journey_selector_question": "What would you like to create?",
+ "journey_selector_story_title": "Story",
+ "journey_selector_story_desc": "A narrative story with images and text.",
+ "journey_selector_journey_title": "Reading Journey",
+ "journey_selector_journey_desc": "A curated selection of letters with notes.",
+ "journey_selector_next_btn": "Continue",
+ "journey_placeholder_back": "different selection",
+ "journey_placeholder_heading": "Reading Journey editor coming in #753",
+ "journey_item_open_aria": "Open letter from {date}",
+ "journey_item_open_aria_undated": "Open letter",
+ "journey_empty_state": "This reading journey is still empty.",
+ "journey_interlude_aria_label": "Curator's note",
+ "journey_selector_aria_live_hint": "Please select a type to continue."
}
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 6a8d9eb3..3c8a361e 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -1159,5 +1159,20 @@
"themen_alle": "Todos los temas",
"themen_leer": "Aún no hay temas.",
"themen_weitere": "+ {count} más",
- "themen_dokumente": "{count} documentos"
+ "themen_dokumente": "{count} documentos",
+ "journey_badge_list": "VIAJE",
+ "journey_badge_detail": "VIAJE DE LECTURA",
+ "journey_selector_question": "¿Qué deseas crear?",
+ "journey_selector_story_title": "Historia",
+ "journey_selector_story_desc": "Una historia narrada con imágenes y texto.",
+ "journey_selector_journey_title": "Viaje de lectura",
+ "journey_selector_journey_desc": "Una selección curada de cartas con notas.",
+ "journey_selector_next_btn": "Continuar",
+ "journey_placeholder_back": "otra selección",
+ "journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753",
+ "journey_item_open_aria": "Abrir carta del {date}",
+ "journey_item_open_aria_undated": "Abrir carta",
+ "journey_empty_state": "Este viaje de lectura está vacío.",
+ "journey_interlude_aria_label": "Nota del curador",
+ "journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar."
}
diff --git a/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts b/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts
index b08e32ff..f842f296 100644
--- a/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts
+++ b/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts
@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
const docs = [makeDoc('d1', 'Only one')];
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
- await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
+ await expect.element(page.getByRole('link', { name: /Alle 50/ })).toBeInTheDocument();
});
});
diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts
index d5518fd5..ceb29071 100644
--- a/frontend/src/lib/generated/api.ts
+++ b/frontend/src/lib/generated/api.ts
@@ -84,6 +84,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/geschichten/{id}/items/reorder": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /**
+ * Reorder journey items
+ * @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request.
+ */
+ put: operations["reorderItems"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/documents/{id}": {
parameters: {
query?: never;
@@ -420,6 +440,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/geschichten/{id}/items": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["appendItem"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/documents": {
parameters: {
query?: never;
@@ -788,6 +824,22 @@ export interface paths {
patch: operations["update"];
trace?: never;
};
+ "/api/geschichten/{id}/items/{itemId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations["deleteItem"];
+ options?: never;
+ head?: never;
+ patch: operations["updateItemNote"];
+ trace?: never;
+ };
"/api/documents/{id}/training-labels": {
parameters: {
query?: never;
@@ -1690,6 +1742,32 @@ export interface components {
provisional: boolean;
readonly displayName: string;
};
+ JourneyReorderDTO: {
+ itemIds?: string[];
+ };
+ DocumentSummary: {
+ /** Format: uuid */
+ id: string;
+ title: string;
+ /** Format: date */
+ documentDate?: string;
+ /** Format: date */
+ documentDateEnd?: string;
+ /** @enum {string} */
+ datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
+ senderName?: string;
+ receiverName?: string;
+ /** Format: int32 */
+ receiverCount: number;
+ };
+ JourneyItemView: {
+ /** Format: uuid */
+ id: string;
+ /** Format: int32 */
+ position: number;
+ document?: components["schemas"]["DocumentSummary"];
+ note?: string;
+ };
DocumentUpdateDTO: {
title?: string;
/** Format: date */
@@ -2163,6 +2241,9 @@ export interface components {
actorName?: string;
documentTitle?: string;
};
+ JourneyItemUpdateDTO: {
+ note?: string;
+ };
TrainingLabelRequest: {
label?: string;
enrolled?: boolean;
@@ -2265,13 +2346,13 @@ export interface components {
lastName?: string;
/** Format: int64 */
documentCount?: number;
+ alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
provisional?: boolean;
- alias?: string;
personType?: string;
familyMember?: boolean;
};
@@ -2402,6 +2483,54 @@ export interface components {
nodes: components["schemas"]["PersonNodeDTO"][];
edges: components["schemas"]["RelationshipDTO"][];
};
+ AuthorSummary: {
+ firstName?: string;
+ lastName?: string;
+ email: string;
+ };
+ GeschichteSummary: {
+ body?: string;
+ title: string;
+ /** Format: uuid */
+ id: string;
+ /** @enum {string} */
+ type: "STORY" | "JOURNEY";
+ /** @enum {string} */
+ status: "DRAFT" | "PUBLISHED";
+ author?: components["schemas"]["AuthorSummary"];
+ /** Format: date-time */
+ publishedAt?: string;
+ };
+ AuthorView: {
+ /** Format: uuid */
+ id: string;
+ displayName: string;
+ };
+ GeschichteView: {
+ /** Format: uuid */
+ id: string;
+ title: string;
+ body?: string;
+ /** @enum {string} */
+ status: "DRAFT" | "PUBLISHED";
+ /** @enum {string} */
+ type: "STORY" | "JOURNEY";
+ author?: components["schemas"]["AuthorView"];
+ persons: components["schemas"]["PersonView"][];
+ items: components["schemas"]["JourneyItemView"][];
+ /** Format: date-time */
+ publishedAt?: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ updatedAt: string;
+ };
+ PersonView: {
+ /** Format: uuid */
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ };
DocumentVersionSummary: {
/** Format: uuid */
id: string;
@@ -2548,7 +2677,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
- kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
+ kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -2858,6 +2987,32 @@ export interface operations {
};
};
};
+ reorderItems: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["JourneyReorderDTO"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["JourneyItemView"][];
+ };
+ };
+ };
+ };
getDocument: {
parameters: {
query?: never;
@@ -3607,6 +3762,32 @@ export interface operations {
};
};
};
+ appendItem: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["JourneyItemCreateDTO"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["JourneyItemView"];
+ };
+ };
+ };
+ };
createDocument: {
parameters: {
query?: never;
@@ -4258,7 +4439,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "*/*": components["schemas"]["Geschichte"];
+ "*/*": components["schemas"]["GeschichteView"];
};
};
};
@@ -4309,6 +4490,54 @@ export interface operations {
};
};
};
+ deleteItem: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ itemId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ updateItemNote: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ itemId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["JourneyItemUpdateDTO"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["JourneyItemView"];
+ };
+ };
+ };
+ };
patchTrainingLabel: {
parameters: {
query?: never;
@@ -5318,7 +5547,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
- kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
+ kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
};
header?: never;
path?: never;
diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte
new file mode 100644
index 00000000..68d8d8e2
--- /dev/null
+++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+ {authorName}
+ {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
+ {plainExcerpt(geschichte.body, 150)}{geschichte.title}
+ {#if isJourney}
+
+ {m.journey_badge_list()}
+
+ {/if}
+
Im Jahr 1923...
', + type: 'STORY' as 'STORY' | 'JOURNEY', + status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT', + author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }, + publishedAt: '2026-04-15T10:00:00Z', + ...overrides +}); + +describe('GeschichteListRow', () => { + it('renders the title', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + await expect + .element(page.getByRole('heading', { level: 2 })) + .toHaveTextContent('Die Reise nach Berlin'); + }); + + it('shows no badge for STORY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows no badge when type is undefined', async () => { + render(GeschichteListRow, { + props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) } + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows REISE badge for JOURNEY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent?.trim()).toBe('REISE'); + }); + + it('badge is a plain , not a nested interactive element', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge?.tagName.toLowerCase()).toBe('span'); + }); + + it('badge has small font size appropriate for a label', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize); + expect(fontSize).toBeGreaterThan(0); + expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size + }); + + it('renders author name in meta line', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + expect(document.body.textContent).toContain('Anna Schmidt'); + }); +}); diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index 90e3bdb7..2aeefc5a 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -121,6 +121,16 @@ describe('GeschichtenCard', () => { expect(link.getAttribute('href')).toBe('/geschichten?personId=p1'); }); + it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => { + render(GeschichtenCard, { + geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }], + personId: 'p1', + personName: 'Franz', + canWrite: false + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + it('renders a plain-text excerpt without HTML markup', async () => { render(GeschichtenCard, { geschichten: [ diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte new file mode 100644 index 00000000..9bfcca9e --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte @@ -0,0 +1,24 @@ + + +{note}
++ + {item.note} +
+{/if} diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts new file mode 100644 index 00000000..75a77516 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import type { components } from '$lib/generated/api'; + +const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_note?: number; + } +} + +type JourneyItemView = components['schemas']['JourneyItemView']; + +const baseItem = (overrides: Partial{introText}
+{/if} + +{#if validItems.length === 0} ++ {m.journey_empty_state()} +
+{:else} +{item.note}
+ {/if} +Im Jahr 1923 fuhr Helene...
', + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides +}); + +const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]); + +describe('StoryReader', () => { + it('renders body HTML content', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: false } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + }); + + it('omits persons section when persons array is empty', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false } + }); + + await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument(); + }); + + it('renders persons section with firstName + lastName joined', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + persons: [ + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } + ] + }), + canBlogWrite: false + } + }); + + await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible(); + await expect.element(page.getByText('Helene Schmidt')).toBeVisible(); + await expect.element(page.getByText('Karl Müller')).toBeVisible(); + }); + + it('omits documents section when no items have documents', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); + }); + + it('renders documents section for items with documents', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + items: [ + { + id: 'i1', + position: 0, + document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' }, + note: 'Wichtiger Brief' + } + ] + }), + canBlogWrite: false + } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); + await expect.element(page.getByText('Dokument öffnen')).toBeVisible(); + await expect.element(page.getByText('Wichtiger Brief')).toBeVisible(); + }); + + it('shows edit/delete actions when canBlogWrite is true', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: true } + }); + + await expect + .element(page.getByRole('link', { name: /bearbeiten/i })) + .toHaveAttribute('href', '/geschichten/g1/edit'); + await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible(); + }); + + it('hides edit/delete actions when canBlogWrite is false', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: false } + }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); + }); + + it('clicking delete button calls ondelete prop', async () => { + const ondelete = vi.fn().mockResolvedValue(undefined); + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: true, ondelete } + }); + + await userEvent.click(page.getByRole('button', { name: /löschen/i })); + + expect(ondelete).toHaveBeenCalledOnce(); + }); + + it('person chip link meets 44px touch-target minimum height', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }] + }), + canBlogWrite: false + } + }); + + const link = document.querySelectorshort
', 80)).toBe('short'); diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 3149d9e2..048ceded 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -1,9 +1,8 @@- {authorName(g)} - {#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if} -
- {#if g.body} -{plainExcerpt(g.body, 150)}
- {/if} - + +- {authorName()} + {authorName} {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
{item.note}
- {/if} -Im Jahr 1923 fuhr Helene...
', - publishedAt: '2026-04-15T10:00:00Z' as string | null, - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as { - firstName?: string; - lastName?: string; - email: string; - } | null, - persons: [] as { id: string; displayName: string }[], - items: [] as { id: string; documentId?: string; position: number; note?: string }[], + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-04-15T10:00:00Z', ...overrides }); @@ -55,9 +81,7 @@ describe('geschichten/[id] page', () => { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ - geschichte: baseGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' } - }) + geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } }) }) } }); @@ -65,10 +89,10 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/fallback@example.com/)).toBeVisible(); }); - it('renders an empty author when author is null', async () => { + it('renders an empty author when author is absent', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) } + props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); @@ -86,7 +110,9 @@ describe('geschichten/[id] page', () => { it('omits the publishedAt suffix when publishedAt is null', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) } + props: { + data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) }) + } }); await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); @@ -108,8 +134,8 @@ describe('geschichten/[id] page', () => { data: baseData({ geschichte: baseGeschichte({ persons: [ - { id: 'p1', displayName: 'Helene Schmidt' }, - { id: 'p2', displayName: 'Karl Müller' } + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } ] }) }) @@ -136,7 +162,14 @@ describe('geschichten/[id] page', () => { props: { data: baseData({ geschichte: baseGeschichte({ - items: [{ id: 'item1', documentId: 'd1', position: 0, note: 'Brief aus 1923' }] + items: [ + { + id: 'item1', + position: 0, + document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' }, + note: 'Brief aus 1923' + } + ] }) }) } @@ -168,4 +201,77 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); }); + + it('STORY with items:[] renders rich-text body and no empty-state message', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { + data: baseData({ + geschichte: baseGeschichte({ + type: undefined as unknown as 'STORY' | 'JOURNEY', + items: [] + }) + }) + } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => { + vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 })); + const confirmService = createConfirmService(); + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, confirmService]]), + props: { data: baseData({ canBlogWrite: true }) } + }); + + // Trigger delete — opens confirm dialog + const deleteBtn = page.getByRole('button', { name: /löschen/i }); + await userEvent.click(deleteBtn); + + // Settle the confirmation dialog + confirmService.settle(true); + + // Wait for the async delete to complete, then check goto was called + await vi.waitFor(() => { + expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten'); + }); + }); + + it('delete failure: shows error message when DELETE returns non-ok', async () => { + vi.mocked(csrfFetch).mockResolvedValue( + new Response(JSON.stringify({ code: 'FORBIDDEN' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }) + ); + const confirmService = createConfirmService(); + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, confirmService]]), + props: { data: baseData({ canBlogWrite: true }) } + }); + + // Trigger delete — opens confirm dialog + const deleteBtn = page.getByRole('button', { name: /löschen/i }); + await userEvent.click(deleteBtn); + + // Settle the confirmation dialog + confirmService.settle(true); + + // Wait for the error to appear inline + await expect.element(page.getByRole('alert')).toBeVisible(); + expect(vi.mocked(goto)).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/routes/geschichten/new/+page.server.ts b/frontend/src/routes/geschichten/new/+page.server.ts index 4763555d..652aa86c 100644 --- a/frontend/src/routes/geschichten/new/+page.server.ts +++ b/frontend/src/routes/geschichten/new/+page.server.ts @@ -19,5 +19,12 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => { const initialPersons = personResult && personResult.response.ok && personResult.data ? [personResult.data] : []; - return { initialPersons }; + // Validate ?type against the known union — prevents unexpected strings from reaching the API. + // Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and + // only the FIRST value is returned by searchParams.get() on repeated params. + const rawType = url.searchParams.get('type'); + const selectedType: 'STORY' | 'JOURNEY' | null = + rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null; + + return { initialPersons, selectedType }; }; diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index ad80d87b..0eff0a68 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -1,42 +1,12 @@{m.journey_placeholder_heading()}
+ + {m.journey_placeholder_back()} ++ {m.journey_selector_question()} +
+ +