feat/issue-751-journey-item-crud-api #791
@@ -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(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(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(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(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 with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
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(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(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.")
|
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(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(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(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "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(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||||
|
|||||||
@@ -1159,5 +1159,20 @@
|
|||||||
"themen_alle": "Alle Themen",
|
"themen_alle": "Alle Themen",
|
||||||
"themen_leer": "Noch keine Themen vergeben.",
|
"themen_leer": "Noch keine Themen vergeben.",
|
||||||
"themen_weitere": "+ {count} weitere",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1159,5 +1159,20 @@
|
|||||||
"themen_alle": "All Topics",
|
"themen_alle": "All Topics",
|
||||||
"themen_leer": "No topics assigned yet.",
|
"themen_leer": "No topics assigned yet.",
|
||||||
"themen_weitere": "+ {count} more",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1159,5 +1159,20 @@
|
|||||||
"themen_alle": "Todos los temas",
|
"themen_alle": "Todos los temas",
|
||||||
"themen_leer": "Aún no hay temas.",
|
"themen_leer": "Aún no hay temas.",
|
||||||
"themen_weitere": "+ {count} más",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
|
|||||||
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
|
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
|
||||||
const docs = [makeDoc('d1', 'Only one')];
|
const docs = [makeDoc('d1', 'Only one')];
|
||||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -420,6 +440,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/documents": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -788,6 +824,22 @@ export interface paths {
|
|||||||
patch: operations["update"];
|
patch: operations["update"];
|
||||||
trace?: never;
|
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": {
|
"/api/documents/{id}/training-labels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1690,6 +1742,32 @@ export interface components {
|
|||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
readonly displayName: string;
|
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: {
|
DocumentUpdateDTO: {
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
@@ -2163,6 +2241,9 @@ export interface components {
|
|||||||
actorName?: string;
|
actorName?: string;
|
||||||
documentTitle?: string;
|
documentTitle?: string;
|
||||||
};
|
};
|
||||||
|
JourneyItemUpdateDTO: {
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
TrainingLabelRequest: {
|
TrainingLabelRequest: {
|
||||||
label?: string;
|
label?: string;
|
||||||
enrolled?: boolean;
|
enrolled?: boolean;
|
||||||
@@ -2265,13 +2346,13 @@ export interface components {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
documentCount?: number;
|
documentCount?: number;
|
||||||
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
provisional?: boolean;
|
provisional?: boolean;
|
||||||
alias?: string;
|
|
||||||
personType?: string;
|
personType?: string;
|
||||||
familyMember?: boolean;
|
familyMember?: boolean;
|
||||||
};
|
};
|
||||||
@@ -2402,6 +2483,54 @@ export interface components {
|
|||||||
nodes: components["schemas"]["PersonNodeDTO"][];
|
nodes: components["schemas"]["PersonNodeDTO"][];
|
||||||
edges: components["schemas"]["RelationshipDTO"][];
|
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: {
|
DocumentVersionSummary: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2548,7 +2677,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ActivityFeedItemDTO: {
|
ActivityFeedItemDTO: {
|
||||||
/** @enum {string} */
|
/** @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"];
|
actor?: components["schemas"]["ActivityActorDTO"];
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
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: {
|
getDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
createDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4258,7 +4439,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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: {
|
patchTrainingLabel: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -5318,7 +5547,7 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
/** @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;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
42
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
42
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||||
|
import { formatAuthorName, formatPublishedAt } from './utils';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteRow = Pick<
|
||||||
|
components['schemas']['GeschichteSummary'],
|
||||||
|
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
let { geschichte }: { geschichte: GeschichteRow } = $props();
|
||||||
|
|
||||||
|
const isJourney = $derived(geschichte.type === 'JOURNEY');
|
||||||
|
|
||||||
|
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
|
||||||
|
|
||||||
|
const authorName = $derived(formatAuthorName(geschichte.author));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a href="/geschichten/{geschichte.id}" class="block">
|
||||||
|
<div class="mb-1 flex items-center gap-1.5">
|
||||||
|
<h2 class="font-serif text-xl font-bold text-ink">{geschichte.title}</h2>
|
||||||
|
{#if isJourney}
|
||||||
|
<span
|
||||||
|
data-testid="journey-badge"
|
||||||
|
style="font-size: 0.75rem"
|
||||||
|
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
|
||||||
|
>
|
||||||
|
{m.journey_badge_list()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">
|
||||||
|
{authorName}
|
||||||
|
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||||
|
</p>
|
||||||
|
{#if geschichte.body}
|
||||||
|
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||||
|
<p class="font-serif text-base text-ink-2">{plainExcerpt(geschichte.body, 150)}</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
65
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
65
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const baseRow = (overrides = {}) => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Die Reise nach Berlin',
|
||||||
|
body: '<p>Im Jahr 1923...</p>',
|
||||||
|
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 <span>, 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,6 +121,16 @@ describe('GeschichtenCard', () => {
|
|||||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
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 () => {
|
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||||
render(GeschichtenCard, {
|
render(GeschichtenCard, {
|
||||||
geschichten: [
|
geschichten: [
|
||||||
|
|||||||
24
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
24
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { note }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="note"
|
||||||
|
aria-label={m.journey_interlude_aria_label()}
|
||||||
|
class="my-2 border-l-4 border-journey-border bg-journey-tint px-4 py-3"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-center font-sans text-xs tracking-widest text-journey uppercase"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
❦
|
||||||
|
</p>
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="font-serif text-base leading-relaxed text-ink-2 italic">{note}</p>
|
||||||
|
</div>
|
||||||
53
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
53
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__xss_interlude?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JourneyInterlude', () => {
|
||||||
|
it('renders the note text as plaintext', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-label from i18n (journey_interlude_aria_label)', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has role="note" so the aria-label is announced by screen readers', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
const el = document.querySelector('[role="note"]');
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the section-break glyph ❦', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('❦');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
|
// Interlude uses Svelte text interpolation ({note}), NOT {@html}.
|
||||||
|
render(JourneyInterlude, {
|
||||||
|
props: { note: '<img src=x onerror="window.__xss_interlude=1">' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.__xss_interlude).toBeUndefined();
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
43
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: JourneyItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { item }: Props = $props();
|
||||||
|
|
||||||
|
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
||||||
|
const doc = $derived(item.document!);
|
||||||
|
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||||
|
const ariaLabel = $derived(
|
||||||
|
formattedDate
|
||||||
|
? m.journey_item_open_aria({ date: formattedDate })
|
||||||
|
: m.journey_item_open_aria_undated()
|
||||||
|
);
|
||||||
|
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/documents/{doc.id}"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
style="display: flex; min-height: 44px; flex-direction: column"
|
||||||
|
class="flex min-h-[44px] flex-col gap-1 rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<span class="font-bold">{doc.title}</span>
|
||||||
|
{#if formattedDate}
|
||||||
|
<span class="font-sans text-sm text-ink-3">{formattedDate}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if hasNote}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="mt-1 flex items-baseline gap-1 font-sans text-sm text-ink-3">
|
||||||
|
<span aria-hidden="true">✎</span>
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
124
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
124
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
@@ -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<JourneyItemView> = {}): JourneyItemView => ({
|
||||||
|
id: 'item1',
|
||||||
|
position: 0,
|
||||||
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief an Helene',
|
||||||
|
documentDate: '1923-05-15',
|
||||||
|
datePrecision: 'FULL'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemCard', () => {
|
||||||
|
it('renders the document title', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the document date when documentDate is present', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whole card is a single <a> element', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const link = document.querySelector('a');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.href).toContain('/documents/d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link has dated aria-label when documentDate is present', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const link = document.querySelector('a');
|
||||||
|
expect(link?.getAttribute('aria-label')).toContain('Brief');
|
||||||
|
expect(link?.getAttribute('aria-label')).toContain('1923');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link has undated aria-label when documentDate is absent', async () => {
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a');
|
||||||
|
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits date text when documentDate is absent', async () => {
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ✎ glyph and note text when note is present', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('✎');
|
||||||
|
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits annotation block when note is blank or whitespace', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain('✎');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits annotation block when note is absent', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain('✎');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('link meets 44px touch-target minimum height', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const link = document.querySelector('a');
|
||||||
|
const rect = link?.getBoundingClientRect();
|
||||||
|
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
|
// Note uses Svelte text interpolation ({note}), NOT {@html}.
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
note: '<img src=x onerror="window.__xss_note=1">'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.__xss_note).toBeUndefined();
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
70
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||||
|
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
canBlogWrite: boolean;
|
||||||
|
ondelete?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
||||||
|
|
||||||
|
// Render intro only when body is a non-empty, non-whitespace string.
|
||||||
|
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||||
|
|
||||||
|
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
|
||||||
|
const validItems = $derived(
|
||||||
|
g.items.filter(
|
||||||
|
(item: JourneyItemView) =>
|
||||||
|
item.document != null || (item.note != null && item.note.trim().length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if introText}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if validItems.length === 0}
|
||||||
|
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
|
||||||
|
{m.journey_empty_state()}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="flex list-none flex-col gap-4">
|
||||||
|
{#each validItems as item (item.id)}
|
||||||
|
<li>
|
||||||
|
{#if item.document != null}
|
||||||
|
<JourneyItemCard item={item} />
|
||||||
|
{:else}
|
||||||
|
<JourneyInterlude note={item.note!} />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Author actions -->
|
||||||
|
{#if canBlogWrite}
|
||||||
|
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
||||||
|
<a
|
||||||
|
href="/geschichten/{g.id}/edit"
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => ondelete?.()}
|
||||||
|
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
185
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
185
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const { default: JourneyReader } = await import('./JourneyReader.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__xss_journey?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Lesereise Berlin',
|
||||||
|
body: null as unknown as undefined,
|
||||||
|
type: 'JOURNEY',
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
persons: [],
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
document: { id: `d${id}`, title, datePrecision: 'FULL', documentDate: '1923-05-15' },
|
||||||
|
note
|
||||||
|
});
|
||||||
|
|
||||||
|
const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
document: undefined,
|
||||||
|
note
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
|
||||||
|
|
||||||
|
describe('JourneyReader', () => {
|
||||||
|
it('renders intro paragraph when body is non-empty', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits intro paragraph when body is null', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only empty state should render
|
||||||
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits intro paragraph when body is only whitespace', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whitespace-only body must NOT produce a visible intro paragraph.
|
||||||
|
// The only rendered content should be the empty-state message.
|
||||||
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||||
|
const paragraphs = document.querySelectorAll('p:not([data-testid])');
|
||||||
|
expect(paragraphs.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty-state message when items array is empty', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both intro and empty-state when body is set but items is empty', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
body: 'Eine Einleitung.',
|
||||||
|
items: []
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders document items (JourneyItemCard)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders interlude items (JourneyInterlude)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
|
||||||
|
expect(document.body.textContent).toContain('❦');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
items: [
|
||||||
|
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
|
||||||
|
docItem('item2', 'Echter Brief', 1)
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Echter Brief')).toBeVisible();
|
||||||
|
// Empty-state must NOT render when valid items exist
|
||||||
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking delete button calls ondelete prop', async () => {
|
||||||
|
const ondelete = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ items: [docItem('i1', 'Brief', 0)] }),
|
||||||
|
canBlogWrite: true,
|
||||||
|
ondelete
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
|
||||||
|
|
||||||
|
expect(ondelete).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
|
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
body: '<img src=x onerror="window.__xss_journey=1">'
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.__xss_journey).toBeUndefined();
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# geschichte (frontend)
|
# geschichte (frontend)
|
||||||
|
|
||||||
UI for family stories: the rich-text editor, story cards, and story list view.
|
UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-text editor, story/journey readers, type badge, and list rows.
|
||||||
|
|
||||||
## What this domain owns
|
## What this domain owns
|
||||||
|
|
||||||
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
|
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
|
||||||
|
Utilities: `utils.ts`.
|
||||||
|
|
||||||
## What this domain does NOT own
|
## What this domain does NOT own
|
||||||
|
|
||||||
@@ -14,14 +15,38 @@ Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
|
|||||||
|
|
||||||
## Key components
|
## Key components
|
||||||
|
|
||||||
| Component | Used in | Notes |
|
| Component | Used in | Notes |
|
||||||
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
|
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
|
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
|
||||||
| `GeschichtenCard.svelte` | `/geschichten` (list), dashboard | Story preview card with cover image and publish status |
|
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
|
||||||
|
| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) |
|
||||||
|
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
|
||||||
|
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
|
||||||
|
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `<a>` for a document item; dated/undated aria-label, ✎ annotation glyph |
|
||||||
|
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` |
|
||||||
|
|
||||||
|
## utils.ts
|
||||||
|
|
||||||
|
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to `email` (for list/summary shapes).
|
||||||
|
`formatAuthorDisplayName(author)` — returns `displayName` (for detail `AuthorView` shape).
|
||||||
|
`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail).
|
||||||
|
|
||||||
|
## Public list is PUBLISHED-only
|
||||||
|
|
||||||
|
`GET /api/geschichten` constrains `status=PUBLISHED`, so DRAFT journeys never appear in the list.
|
||||||
|
The REISE badge is only ever seen for published journeys.
|
||||||
|
Empty-state and draft-preview paths are reachable ONLY via the **detail route** (`/geschichten/[id]`), not the list.
|
||||||
|
Wire empty-state E2E tests through the detail route, not by expecting a draft journey in the list.
|
||||||
|
|
||||||
|
## TypeSelector route component
|
||||||
|
|
||||||
|
`TypeSelector.svelte` lives in `src/routes/geschichten/new/` (single-use route UI).
|
||||||
|
It is NOT in `$lib/geschichte/` — route-specific, not reused elsewhere.
|
||||||
|
`StoryCreate.svelte` (also in `new/`) wraps `GeschichteEditor` so tree-shaking excludes TipTap from the JOURNEY placeholder path.
|
||||||
|
|
||||||
## Audience note
|
## Audience note
|
||||||
|
|
||||||
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone.
|
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone. JourneyReader mobile layout is Critical; TypeSelector is Minor.
|
||||||
|
|
||||||
## Cross-domain imports
|
## Cross-domain imports
|
||||||
|
|
||||||
|
|||||||
102
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
102
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
canBlogWrite: boolean;
|
||||||
|
ondelete?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
||||||
|
|
||||||
|
const sanitized = $derived(safeHtml(g.body));
|
||||||
|
|
||||||
|
function personName(p: { firstName?: string; lastName?: string }): string {
|
||||||
|
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
||||||
|
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
||||||
|
and produces a much narrower column inside an already narrow page, which
|
||||||
|
Leonie flagged as unreadable for the senior-author persona.
|
||||||
|
|
||||||
|
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html sanitized}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personen -->
|
||||||
|
{#if g.persons && g.persons.length > 0}
|
||||||
|
<section class="mt-10 border-t border-line pt-6">
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.geschichten_persons_section()}
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{#each g.persons as p (p.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/persons/{p.id}"
|
||||||
|
style="display: inline-flex; min-height: 44px"
|
||||||
|
class="inline-flex min-h-[44px] items-center rounded-full bg-muted px-3 py-2.5 font-sans text-sm text-ink hover:bg-accent-bg focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{personName(p)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dokumente (JourneyItems) -->
|
||||||
|
{#if g.items && g.items.some((i) => i.document)}
|
||||||
|
<section class="mt-8 border-t border-line pt-6">
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.geschichten_documents_section()}
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{#each g.items.filter((i) => i.document) as item (item.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/documents/{item.document!.id}"
|
||||||
|
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.geschichten_document_link_placeholder()}
|
||||||
|
</a>
|
||||||
|
{#if item.note}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Author actions -->
|
||||||
|
{#if canBlogWrite}
|
||||||
|
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
||||||
|
<a
|
||||||
|
href="/geschichten/{g.id}/edit"
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => ondelete?.()}
|
||||||
|
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
163
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
163
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const { default: StoryReader } = await import('./StoryReader.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Die Reise nach Berlin',
|
||||||
|
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
||||||
|
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.querySelector<HTMLAnchorElement>('a[href^="/persons/"]');
|
||||||
|
const rect = link?.getBoundingClientRect();
|
||||||
|
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
||||||
|
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
body: '<img src=x onerror="(window as any).__xss_story=1">'
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as { __xss_story?: number }).__xss_story).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/lib/geschichte/utils.test.ts
Normal file
67
frontend/src/lib/geschichte/utils.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './utils';
|
||||||
|
|
||||||
|
describe('formatAuthorName', () => {
|
||||||
|
it('joins firstName and lastName with a space', () => {
|
||||||
|
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe(
|
||||||
|
'Anna Schmidt'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns firstName alone when lastName is absent', () => {
|
||||||
|
expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns lastName alone when firstName is absent', () => {
|
||||||
|
expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to email when both names are absent', () => {
|
||||||
|
expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for null input', () => {
|
||||||
|
expect(formatAuthorName(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for undefined input', () => {
|
||||||
|
expect(formatAuthorName(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAuthorDisplayName', () => {
|
||||||
|
it('returns displayName when present', () => {
|
||||||
|
expect(formatAuthorDisplayName({ displayName: 'Anna Schmidt' })).toBe('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for null input', () => {
|
||||||
|
expect(formatAuthorDisplayName(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for undefined input', () => {
|
||||||
|
expect(formatAuthorDisplayName(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPublishedAt', () => {
|
||||||
|
it('returns null for null input', () => {
|
||||||
|
expect(formatPublishedAt(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for undefined input', () => {
|
||||||
|
expect(formatPublishedAt(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats an ISO datetime string to a localised date', () => {
|
||||||
|
const result = formatPublishedAt('2026-04-15T10:00:00Z', 'short');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result).toContain('2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices to date-only before formatting (no TZ off-by-one)', () => {
|
||||||
|
// Both dates should format identically regardless of timezone offset
|
||||||
|
const a = formatPublishedAt('2026-04-15T00:00:00Z', 'short');
|
||||||
|
const b = formatPublishedAt('2026-04-15T23:59:59Z', 'short');
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
frontend/src/lib/geschichte/utils.ts
Normal file
22
frontend/src/lib/geschichte/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
|
||||||
|
type AuthorSummary = { firstName?: string; lastName?: string; email: string };
|
||||||
|
type AuthorView = { displayName: string };
|
||||||
|
|
||||||
|
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
||||||
|
if (!author) return '';
|
||||||
|
const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return full || author.email || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
|
||||||
|
return author?.displayName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPublishedAt(
|
||||||
|
publishedAt: string | null | undefined,
|
||||||
|
style: 'short' | 'long' = 'short'
|
||||||
|
): string | null {
|
||||||
|
if (!publishedAt) return null;
|
||||||
|
return formatDate(publishedAt.slice(0, 10), style);
|
||||||
|
}
|
||||||
@@ -18,9 +18,8 @@ export function radioGroupNav(
|
|||||||
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
||||||
const next = (current + delta + radios.length) % radios.length;
|
const next = (current + delta + radios.length) % radios.length;
|
||||||
|
|
||||||
radios[current].setAttribute('aria-checked', 'false');
|
|
||||||
radios[next].setAttribute('aria-checked', 'true');
|
|
||||||
radios[next].focus();
|
radios[next].focus();
|
||||||
|
radios.forEach((r, i) => r.setAttribute('aria-checked', i === next ? 'true' : 'false'));
|
||||||
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ describe('extractText', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project.
|
||||||
|
// The browser project's DOMParser would silently take the safe branch → false green.
|
||||||
|
// This test fires the regex fallback specifically (Node has no DOMParser).
|
||||||
|
describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => {
|
||||||
|
it('does not emit onerror= in output when given an <img onerror> payload (security regression)', () => {
|
||||||
|
// plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser).
|
||||||
|
// SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk.
|
||||||
|
const out = plainExcerpt('<img src=x onerror="window.__xss=1">');
|
||||||
|
expect(out).not.toContain('onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('plainExcerpt', () => {
|
describe('plainExcerpt', () => {
|
||||||
it('returns full text when under the limit', () => {
|
it('returns full text when under the limit', () => {
|
||||||
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
|
||||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||||
|
import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -36,18 +35,6 @@ function addPerson(personId: string) {
|
|||||||
function removePerson(personId: string) {
|
function removePerson(personId: string) {
|
||||||
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
|
||||||
const a = g.author;
|
|
||||||
if (!a) return '';
|
|
||||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
|
||||||
return full || a.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishedAt(g: { publishedAt?: string }): string | null {
|
|
||||||
if (!g.publishedAt) return null;
|
|
||||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
@@ -131,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
|||||||
<li
|
<li
|
||||||
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
|
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||||
>
|
>
|
||||||
<a href="/geschichten/{g.id}" class="block">
|
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||||
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
|
<GeschichteListRow geschichte={g} />
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">
|
|
||||||
{authorName(g)}
|
|
||||||
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
|
|
||||||
</p>
|
|
||||||
{#if g.body}
|
|
||||||
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { safeHtml } from '$lib/shared/utils/sanitize';
|
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
||||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
|
import StoryReader from '$lib/geschichte/StoryReader.svelte';
|
||||||
|
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const g = $derived(data.geschichte);
|
const g = $derived(data.geschichte);
|
||||||
const sanitized = $derived(safeHtml(g.body));
|
const isJourney = $derived(g.type === 'JOURNEY');
|
||||||
|
|
||||||
const publishedAt = $derived.by(() => {
|
const publishedAt = $derived.by(() => {
|
||||||
if (!g.publishedAt) return null;
|
if (!g.publishedAt) return null;
|
||||||
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
||||||
});
|
});
|
||||||
|
|
||||||
function authorName(): string {
|
const authorName = $derived(formatAuthorDisplayName(g.author));
|
||||||
const a = g.author;
|
|
||||||
if (!a) return '';
|
|
||||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
|
||||||
return full || a.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirm = getConfirmService();
|
const confirm = getConfirmService();
|
||||||
|
|
||||||
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
deleteError = null;
|
||||||
const ok = await confirm.confirm({
|
const ok = await confirm.confirm({
|
||||||
title: m.geschichte_delete_confirm_title(),
|
title: m.geschichte_delete_confirm_title(),
|
||||||
body: m.geschichte_delete_confirm_body(),
|
body: m.geschichte_delete_confirm_body(),
|
||||||
@@ -39,6 +40,9 @@ async function handleDelete() {
|
|||||||
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
goto('/geschichten');
|
goto('/geschichten');
|
||||||
|
} else {
|
||||||
|
const err = await parseBackendError(res);
|
||||||
|
deleteError = getErrorMessage(err?.code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -50,93 +54,37 @@ async function handleDelete() {
|
|||||||
|
|
||||||
<article aria-labelledby="geschichte-title">
|
<article aria-labelledby="geschichte-title">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||||
{g.title}
|
<h1 id="geschichte-title" class="font-serif text-4xl font-bold text-ink">
|
||||||
</h1>
|
{g.title}
|
||||||
|
</h1>
|
||||||
|
{#if isJourney}
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
|
||||||
|
>
|
||||||
|
{m.journey_badge_detail()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<p class="font-sans text-sm text-ink-3">
|
<p class="font-sans text-sm text-ink-3">
|
||||||
{authorName()}
|
{authorName}
|
||||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!--
|
{#if deleteError}
|
||||||
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
<p
|
||||||
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
role="alert"
|
||||||
and produces a much narrower column inside an already narrow page, which
|
class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger"
|
||||||
Leonie flagged as unreadable for the senior-author persona.
|
>
|
||||||
|
{deleteError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
|
{#if isJourney}
|
||||||
-->
|
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||||
<div
|
{:else}
|
||||||
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
|
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||||
>
|
{/if}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html sanitized}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Personen -->
|
|
||||||
{#if g.persons && g.persons.length > 0}
|
|
||||||
<section class="mt-10 border-t border-line pt-6">
|
|
||||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
|
||||||
{m.geschichten_persons_section()}
|
|
||||||
</h2>
|
|
||||||
<ul class="flex flex-wrap gap-2">
|
|
||||||
{#each g.persons as p (p.id)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/persons/{p.id}"
|
|
||||||
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
|
|
||||||
>
|
|
||||||
{p.displayName}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Dokumente (JourneyItems) -->
|
|
||||||
{#if g.items && g.items.some((i) => i.documentId)}
|
|
||||||
<section class="mt-8 border-t border-line pt-6">
|
|
||||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
|
||||||
{m.geschichten_documents_section()}
|
|
||||||
</h2>
|
|
||||||
<ul class="flex flex-col gap-2">
|
|
||||||
{#each g.items.filter((i) => i.documentId) as item (item.id)}
|
|
||||||
<!-- TODO(#786): replace placeholder with actual document title once journey reader is built -->
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/documents/{item.documentId}"
|
|
||||||
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
>
|
|
||||||
{m.geschichten_document_link_placeholder()}
|
|
||||||
</a>
|
|
||||||
{#if item.note}
|
|
||||||
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Author actions -->
|
|
||||||
{#if data.canBlogWrite}
|
|
||||||
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
|
||||||
<a
|
|
||||||
href="/geschichten/{g.id}/edit"
|
|
||||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleDelete}
|
|
||||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
>
|
|
||||||
{m.btn_delete()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,51 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/shared/cookies', () => ({
|
||||||
|
csrfFetch: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
const { default: GeschichtePage } = await import('./+page.svelte');
|
const { default: GeschichtePage } = await import('./+page.svelte');
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
const baseGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
id: 'g1',
|
id: 'g1',
|
||||||
title: 'Die Reise nach Berlin',
|
title: 'Die Reise nach Berlin',
|
||||||
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
||||||
publishedAt: '2026-04-15T10:00:00Z' as string | null,
|
type: 'STORY',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as {
|
status: 'PUBLISHED',
|
||||||
firstName?: string;
|
author: { id: 'u1', displayName: 'Anna Schmidt' },
|
||||||
lastName?: string;
|
persons: [],
|
||||||
email: string;
|
items: [],
|
||||||
} | null,
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
persons: [] as { id: string; displayName: string }[],
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
items: [] as { id: string; documentId?: string; position: number; note?: string }[],
|
publishedAt: '2026-04-15T10:00:00Z',
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,9 +81,7 @@ describe('geschichten/[id] page', () => {
|
|||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } })
|
||||||
author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -65,10 +89,10 @@ describe('geschichten/[id] page', () => {
|
|||||||
await expect.element(page.getByText(/fallback@example.com/)).toBeVisible();
|
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, {
|
render(GeschichtePage, {
|
||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
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();
|
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 () => {
|
it('omits the publishedAt suffix when publishedAt is null', async () => {
|
||||||
render(GeschichtePage, {
|
render(GeschichtePage, {
|
||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
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();
|
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
|
||||||
@@ -108,8 +134,8 @@ describe('geschichten/[id] page', () => {
|
|||||||
data: baseData({
|
data: baseData({
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({
|
||||||
persons: [
|
persons: [
|
||||||
{ id: 'p1', displayName: 'Helene Schmidt' },
|
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||||
{ id: 'p2', displayName: 'Karl Müller' }
|
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -136,7 +162,14 @@ describe('geschichten/[id] page', () => {
|
|||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
geschichte: baseGeschichte({
|
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('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('button', { name: /löschen/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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,5 +19,12 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
|||||||
const initialPersons =
|
const initialPersons =
|
||||||
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
|
||||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import TypeSelector from './TypeSelector.svelte';
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
import StoryCreate from './StoryCreate.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let submitting = $state(false);
|
|
||||||
let errorMessage: string | null = $state(null);
|
|
||||||
|
|
||||||
async function handleSubmit(payload: {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
status: 'DRAFT' | 'PUBLISHED';
|
|
||||||
personIds: string[];
|
|
||||||
}) {
|
|
||||||
submitting = true;
|
|
||||||
errorMessage = null;
|
|
||||||
try {
|
|
||||||
const res = await csrfFetch('/api/geschichten', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const code = (await res.json().catch(() => ({})))?.code;
|
|
||||||
errorMessage = getErrorMessage(code);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const created = await res.json();
|
|
||||||
goto(`/geschichten/${created.id}`);
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||||
@@ -46,18 +16,16 @@ async function handleSubmit(payload: {
|
|||||||
|
|
||||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if data.selectedType === 'STORY'}
|
||||||
<div
|
<StoryCreate initialPersons={data.initialPersons} />
|
||||||
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
{:else if data.selectedType === 'JOURNEY'}
|
||||||
role="alert"
|
<div data-testid="journey-placeholder">
|
||||||
>
|
<p class="mb-4 font-sans text-base text-ink-2">{m.journey_placeholder_heading()}</p>
|
||||||
{errorMessage}
|
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
|
||||||
|
{m.journey_placeholder_back()}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<GeschichteEditor
|
|
||||||
initialPersons={data.initialPersons}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
submitting={submitting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
frontend/src/routes/geschichten/new/StoryCreate.svelte
Normal file
50
frontend/src/routes/geschichten/new/StoryCreate.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||||
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialPersons: components['schemas']['Person'][];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { initialPersons }: Props = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
|
async function handleSubmit(payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
}) {
|
||||||
|
submitting = true;
|
||||||
|
errorMessage = null;
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch('/api/geschichten', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...payload, type: 'STORY' })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
errorMessage = getErrorMessage(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await res.json();
|
||||||
|
goto(`/geschichten/${created.id}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GeschichteEditor initialPersons={initialPersons} onSubmit={handleSubmit} submitting={submitting} />
|
||||||
97
frontend/src/routes/geschichten/new/TypeSelector.svelte
Normal file
97
frontend/src/routes/geschichten/new/TypeSelector.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||||
|
|
||||||
|
type GeschichteType = 'STORY' | 'JOURNEY';
|
||||||
|
|
||||||
|
const TYPES: GeschichteType[] = ['STORY', 'JOURNEY'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onweiter: (type: GeschichteType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onweiter }: Props = $props();
|
||||||
|
|
||||||
|
let selected = $state<GeschichteType | null>(null);
|
||||||
|
let announcement = $state('');
|
||||||
|
|
||||||
|
// Roving-tabindex holder: falls back to the first card so keyboard nav can start
|
||||||
|
// even when nothing is selected (all cards at tabindex=-1 would be a keyboard dead-spot).
|
||||||
|
const rovingFocusType = $derived(selected ?? TYPES[0]);
|
||||||
|
|
||||||
|
function select(type: GeschichteType) {
|
||||||
|
selected = type;
|
||||||
|
announcement = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWeiter() {
|
||||||
|
if (!selected) {
|
||||||
|
announcement = m.journey_selector_aria_live_hint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onweiter(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles: Record<GeschichteType, () => string> = {
|
||||||
|
STORY: m.journey_selector_story_title,
|
||||||
|
JOURNEY: m.journey_selector_journey_title
|
||||||
|
};
|
||||||
|
|
||||||
|
const descs: Record<GeschichteType, () => string> = {
|
||||||
|
STORY: m.journey_selector_story_desc,
|
||||||
|
JOURNEY: m.journey_selector_journey_desc
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p id="type-selector-label" class="mb-4 font-sans text-base font-medium text-ink">
|
||||||
|
{m.journey_selector_question()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-labelledby="type-selector-label"
|
||||||
|
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||||
|
use:radioGroupNav={(v) => {
|
||||||
|
if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each TYPES as type (type)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value={type}
|
||||||
|
aria-checked={selected === type}
|
||||||
|
tabindex={type === rovingFocusType ? 0 : -1}
|
||||||
|
onclick={() => select(type)}
|
||||||
|
class="min-h-[64px] cursor-pointer rounded border px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected === type
|
||||||
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||||
|
>
|
||||||
|
<span class="block font-sans text-sm font-bold">{titles[type]()}</span>
|
||||||
|
<span class="mt-1 block font-sans text-xs text-current opacity-70">{descs[type]()}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>
|
||||||
|
|
||||||
|
{#if !selected}
|
||||||
|
<p id="type-hint" class="mt-3 font-sans text-xs text-ink-3" aria-hidden="true">
|
||||||
|
{m.journey_selector_aria_live_hint()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-disabled={selected == null ? 'true' : 'false'}
|
||||||
|
aria-describedby={selected == null ? 'type-hint' : undefined}
|
||||||
|
tabindex="0"
|
||||||
|
onclick={handleWeiter}
|
||||||
|
class="mt-6 inline-flex h-11 items-center rounded px-6 font-sans text-sm font-medium transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected == null
|
||||||
|
? 'cursor-not-allowed bg-primary text-primary-fg opacity-50'
|
||||||
|
: 'bg-primary text-primary-fg hover:opacity-90'}"
|
||||||
|
>
|
||||||
|
{m.journey_selector_next_btn()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
123
frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts
Normal file
123
frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
goto: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { default: TypeSelector } = await import('./TypeSelector.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('TypeSelector', () => {
|
||||||
|
it('renders both type cards', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible();
|
||||||
|
await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('radiogroup is correctly labelled', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const group = document.querySelector('[role="radiogroup"]');
|
||||||
|
const labelledBy = group?.getAttribute('aria-labelledby');
|
||||||
|
const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
|
||||||
|
expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Weiter button has aria-disabled=true when nothing is selected', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
|
||||||
|
expect(weiter?.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no card is aria-checked when nothing is selected', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
|
||||||
|
const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true');
|
||||||
|
expect(anyChecked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
|
||||||
|
expect(radios[0]?.getAttribute('tabindex')).toBe('0');
|
||||||
|
expect(radios[1]?.getAttribute('tabindex')).toBe('-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking STORY card sets aria-checked=true and enables Weiter', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||||
|
await userEvent.click(storyCard);
|
||||||
|
|
||||||
|
await expect.element(storyCard).toHaveAttribute('aria-checked', 'true');
|
||||||
|
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
|
||||||
|
expect(weiter?.getAttribute('aria-disabled')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking JOURNEY card sets aria-checked=true', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||||
|
await userEvent.click(journeyCard);
|
||||||
|
|
||||||
|
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Weiter after selection calls onweiter with the selected type', async () => {
|
||||||
|
const onweiter = vi.fn();
|
||||||
|
render(TypeSelector, { props: { onweiter } });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('radio', { name: /Geschichte/i }));
|
||||||
|
const weiter = page.getByRole('button', { name: /Weiter/i });
|
||||||
|
await userEvent.click(weiter);
|
||||||
|
|
||||||
|
expect(onweiter).toHaveBeenCalledWith('STORY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Weiter without selection does NOT call onweiter', async () => {
|
||||||
|
const onweiter = vi.fn();
|
||||||
|
render(TypeSelector, { props: { onweiter } });
|
||||||
|
|
||||||
|
// aria-disabled="true" prevents Playwright actionability — dispatch via DOM to test handler behaviour
|
||||||
|
const weiter = document.querySelector<HTMLButtonElement>('button[aria-disabled="true"]');
|
||||||
|
weiter?.click();
|
||||||
|
|
||||||
|
expect(onweiter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('instructional text is visible when no type is selected', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight moves focus and selection from STORY to JOURNEY', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||||
|
await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
|
||||||
|
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||||
|
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect.element(storyCard).toHaveAttribute('aria-checked', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowLeft wraps from STORY back to JOURNEY', async () => {
|
||||||
|
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||||
|
|
||||||
|
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||||
|
await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
|
||||||
|
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||||
|
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
frontend/src/routes/geschichten/new/page.server.test.ts
Normal file
77
frontend/src/routes/geschichten/new/page.server.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { API_INTERNAL_URL: 'http://backend:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
|
createApiClient: vi.fn(() => ({
|
||||||
|
GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null })
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { load } from './+page.server';
|
||||||
|
|
||||||
|
function makeEvent(search: string, canBlogWrite = true) {
|
||||||
|
return {
|
||||||
|
url: new URL(`http://localhost/geschichten/new${search}`),
|
||||||
|
request: new Request(`http://localhost/geschichten/new${search}`),
|
||||||
|
fetch: vi.fn(),
|
||||||
|
parent: vi.fn().mockResolvedValue({ canBlogWrite })
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('geschichten/new load — selectedType validation (security regression)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selectedType: STORY for ?type=STORY', async () => {
|
||||||
|
const result = await load(makeEvent('?type=STORY'));
|
||||||
|
expect(result.selectedType).toBe('STORY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => {
|
||||||
|
const result = await load(makeEvent('?type=JOURNEY'));
|
||||||
|
expect(result.selectedType).toBe('JOURNEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selectedType: null when ?type param is absent', async () => {
|
||||||
|
const result = await load(makeEvent(''));
|
||||||
|
expect(result.selectedType).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selectedType: null for invalid ?type param (security regression)', async () => {
|
||||||
|
const result = await load(makeEvent('?type=ADMIN'));
|
||||||
|
expect(result.selectedType).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => {
|
||||||
|
// Strict equality rejects encoded variants; .includes/.startsWith would not.
|
||||||
|
const result = await load(makeEvent('?type=STORY%00JOURNEY'));
|
||||||
|
expect(result.selectedType).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => {
|
||||||
|
// url.searchParams.get() returns the first value; this is intentional and documented.
|
||||||
|
const result = await load(makeEvent('?type=STORY&type=JOURNEY'));
|
||||||
|
expect(result.selectedType).toBe('STORY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => {
|
||||||
|
const { createApiClient } = await import('$lib/shared/api.server');
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({
|
||||||
|
GET: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } })
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await load(makeEvent('?type=STORY&personId=p1'));
|
||||||
|
expect(result.selectedType).toBe('STORY');
|
||||||
|
expect(result.initialPersons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects non-BLOG_WRITE users to /geschichten', async () => {
|
||||||
|
await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,32 +20,34 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte');
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const baseData = {
|
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||||
initialPersons: [] as { id: string; displayName: string }[]
|
initialPersons: [] as { id: string; displayName: string }[],
|
||||||
};
|
selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
describe('geschichten/new page', () => {
|
describe('geschichten/new page', () => {
|
||||||
it('renders the page heading', async () => {
|
it('renders the page heading', async () => {
|
||||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||||
|
|
||||||
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
|
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a button (BackButton component)', async () => {
|
it('renders a button (BackButton component)', async () => {
|
||||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||||
|
|
||||||
const buttons = document.querySelectorAll('button');
|
const buttons = document.querySelectorAll('button');
|
||||||
expect(buttons.length).toBeGreaterThan(0);
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render an error banner by default', async () => {
|
it('does not render an error banner by default', async () => {
|
||||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||||
|
|
||||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the GeschichteEditor child component', async () => {
|
it('renders the GeschichteEditor when selectedType is STORY', async () => {
|
||||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } });
|
||||||
|
|
||||||
// Editor renders inputs/textarea — verify at least one form input is present
|
// Editor renders inputs/textarea — verify at least one form input is present
|
||||||
const inputs = document.querySelectorAll('input, textarea');
|
const inputs = document.querySelectorAll('input, textarea');
|
||||||
@@ -55,12 +57,51 @@ describe('geschichten/new page', () => {
|
|||||||
it('passes initialPersons through to the editor', async () => {
|
it('passes initialPersons through to the editor', async () => {
|
||||||
render(GeschichtenNewPage, {
|
render(GeschichtenNewPage, {
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: baseData({
|
||||||
|
selectedType: 'STORY',
|
||||||
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
|
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows TypeSelector radiogroup when selectedType is null', async () => {
|
||||||
|
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } });
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('radiogroup')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => {
|
||||||
|
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
|
||||||
|
|
||||||
|
const placeholder = document.querySelector('[data-testid="journey-placeholder"]');
|
||||||
|
expect(placeholder).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY placeholder offers a return-to-selection link', async () => {
|
||||||
|
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
|
||||||
|
|
||||||
|
const backLink = page.getByRole('link', { name: /andere Auswahl/i });
|
||||||
|
await expect.element(backLink).toBeVisible();
|
||||||
|
await expect.element(backLink).toHaveAttribute('href', '/geschichten/new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => {
|
||||||
|
const { goto } = await import('$app/navigation');
|
||||||
|
vi.mocked(goto).mockClear();
|
||||||
|
|
||||||
|
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } });
|
||||||
|
|
||||||
|
// Select STORY
|
||||||
|
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||||
|
await storyCard.click();
|
||||||
|
|
||||||
|
// Click Weiter
|
||||||
|
const weiter = page.getByRole('button', { name: /Weiter/i });
|
||||||
|
await weiter.click();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,19 @@ describe('geschichten page — multi-person filter chips', () => {
|
|||||||
window.history.replaceState({}, '', originalHref);
|
window.history.replaceState({}, '', originalHref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
geschichten: [
|
||||||
|
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
|
||||||
|
] as PageData['geschichten']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the "+ Person wählen" button even when filters are already active', async () => {
|
it('shows the "+ Person wählen" button even when filters are already active', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: makeData({
|
data: makeData({
|
||||||
|
|||||||
@@ -188,8 +188,9 @@ describe('geschichten/+ page', () => {
|
|||||||
// No "·" separator before date when no publishedAt
|
// No "·" separator before date when no publishedAt
|
||||||
const titleHeading = document.querySelector('h2');
|
const titleHeading = document.querySelector('h2');
|
||||||
const card = titleHeading?.closest('li');
|
const card = titleHeading?.closest('li');
|
||||||
// The middle paragraph (author line) should not contain "·"
|
|
||||||
expect(card?.textContent).toContain('Anna Schmidt');
|
expect(card?.textContent).toContain('Anna Schmidt');
|
||||||
|
// "·" separator must be absent when there is no publishedAt date
|
||||||
|
expect(card?.textContent).not.toContain('·');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the body excerpt when body is empty', async () => {
|
it('omits the body excerpt when body is empty', async () => {
|
||||||
|
|||||||
@@ -77,6 +77,11 @@
|
|||||||
--color-warning: #b45309;
|
--color-warning: #b45309;
|
||||||
--color-warning-fg: #ffffff;
|
--color-warning-fg: #ffffff;
|
||||||
|
|
||||||
|
/* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */
|
||||||
|
--color-journey-tint: var(--c-journey-bg);
|
||||||
|
--color-journey: var(--c-journey-text);
|
||||||
|
--color-journey-border: var(--c-journey-border);
|
||||||
|
|
||||||
/* Static brand tokens (not themed) */
|
/* Static brand tokens (not themed) */
|
||||||
--color-brand-navy: var(--palette-navy);
|
--color-brand-navy: var(--palette-navy);
|
||||||
--color-brand-mint: var(--palette-mint);
|
--color-brand-mint: var(--palette-mint);
|
||||||
@@ -128,6 +133,12 @@
|
|||||||
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
|
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
|
||||||
--c-parchment: #faf8f1;
|
--c-parchment: #faf8f1;
|
||||||
|
|
||||||
|
/* Journey / Lesereise — orange semantic tokens
|
||||||
|
Text #7A3F0E on bg #FEF0E6 ≈ 7.4:1 — WCAG AAA ✓ (text-xs requires 4.5:1 normal-text) */
|
||||||
|
--c-journey-bg: #fef0e6;
|
||||||
|
--c-journey-text: #7a3f0e;
|
||||||
|
--c-journey-border: #f0c99a;
|
||||||
|
|
||||||
/* Tag color tokens — decorative dot colors on tag chips */
|
/* Tag color tokens — decorative dot colors on tag chips */
|
||||||
--c-tag-sage: #5a8a6a;
|
--c-tag-sage: #5a8a6a;
|
||||||
--c-tag-sienna: #a0522d;
|
--c-tag-sienna: #a0522d;
|
||||||
@@ -246,6 +257,12 @@
|
|||||||
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
|
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
|
||||||
visibility parity with the 8% light-mode token. Decorative carve-out. */
|
visibility parity with the 8% light-mode token. Decorative carve-out. */
|
||||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||||
|
|
||||||
|
/* Journey / Lesereise — muted warm tint on dark navy; text #E8862A on
|
||||||
|
#3A2A1A ≈ 5.2:1 — WCAG AA ✓ (text-xs requires 4.5:1 normal-text) */
|
||||||
|
--c-journey-bg: #3a2a1a;
|
||||||
|
--c-journey-text: #e8862a;
|
||||||
|
--c-journey-border: #7a4a1e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +338,11 @@
|
|||||||
|
|
||||||
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
|
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
|
||||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||||
|
|
||||||
|
/* Journey / Lesereise — KEEP IN SYNC with the @media block above */
|
||||||
|
--c-journey-bg: #3a2a1a;
|
||||||
|
--c-journey-text: #e8862a;
|
||||||
|
--c-journey-border: #7a4a1e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||||
|
|||||||
Reference in New Issue
Block a user