feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s

This commit was merged in pull request #787.
This commit is contained in:
2026-06-12 14:04:02 +02:00
parent 4bcf568ed4
commit b33d0eb850
142 changed files with 11643 additions and 917 deletions

3
frontend/.gitignore vendored
View File

@@ -13,6 +13,9 @@ node_modules
.DS_Store
Thumbs.db
# Leftover directory from branch work
/src.main/
# Env
.env
.env.*

View File

@@ -7,6 +7,7 @@ bun.lockb
# Miscellaneous
/static/
/src.main/
# Build artifacts
/.svelte-kit/
@@ -19,6 +20,7 @@ bun.lockb
/src/lib/paraglide/
/src/lib/paraglide_bak*/
/src/paraglide/
/src.main/
/project.inlang/
# Test artifacts

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Namen tippen...",
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_typeahead_no_results": "Keine Treffer",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
@@ -1023,6 +1025,10 @@
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
"journey_item_document_deleted": "[Dokument gelöscht]",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle",
@@ -1033,10 +1039,15 @@
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_filter_document_chip": "Gefiltert nach Brief:",
"geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen",
"geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",
"journey_compiled_on": "zusammengestellt am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte",
"geschichten_documents_section": "Erwähnte Dokumente",
"geschichten_document_link_placeholder": "Dokument öffnen",
"geschichten_card_heading": "Geschichten",
"geschichten_card_write_action": "+ Geschichte schreiben",
"geschichten_card_attach_action": "+ Geschichte anhängen",
@@ -1044,6 +1055,7 @@
"geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "ENTWURF",
"geschichte_editor_status_published": "VERÖFFENTLICHT",
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
@@ -1058,8 +1070,17 @@
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"geschichte_editor_personen_heading": "Personen",
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
"geschichte_editor_dokumente_heading": "Dokumente",
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
"geschichte_documents_heading": "Briefe & Dokumente",
"geschichte_documents_hint": "Welche Dokumente gehören zu dieser Geschichte?",
"geschichte_documents_empty": "Noch keine Dokumente verknüpft. Suche unten nach einem Brief, um ihn dieser Geschichte hinzuzufügen.",
"geschichte_documents_picker_label": "Dokument hinzufügen",
"geschichte_documents_picker_placeholder": "Brief oder Dokument suchen…",
"geschichte_documents_deleted_placeholder": "Dokument wurde gelöscht",
"geschichte_documents_remove_label": "Dokument entfernen: {title}",
"geschichte_documents_capacity": "Diese Geschichte hat bereits die maximale Anzahl von Dokumenten (100) erreicht.",
"geschichte_documents_duplicate": "Dieses Dokument ist bereits mit der Geschichte verknüpft.",
"geschichte_documents_added_announce": "Hinzugefügt: {title}",
"geschichte_documents_removed_announce": "Entfernt: {title}",
"geschichte_editor_search_person": "Person suchen…",
"geschichte_editor_search_document": "Dokument suchen…",
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
@@ -1153,5 +1174,58 @@
"themen_alle": "Alle Themen",
"themen_leer": "Noch keine Themen vergeben.",
"themen_weitere": "+ {count} weitere",
"themen_dokumente": "{count} Dokumente"
"themen_dokumente": "{count} Dokumente",
"journey_badge_list": "REISE",
"journey_badge_detail": "LESEREISE",
"journey_selector_question": "Was möchtest du erstellen?",
"journey_selector_story_title": "Geschichte",
"journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
"journey_selector_journey_title": "Lesereise",
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
"journey_selector_next_btn": "Weiter",
"journey_placeholder_back": "andere Auswahl",
"journey_create_submit": "Lesereise erstellen",
"journey_item_open_aria": "Brief vom {date} öffnen",
"journey_item_open_aria_undated": "Brief öffnen",
"journey_item_open": "Brief öffnen",
"journey_item_meta_from_to": "von {sender} an {receiver}",
"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.",
"journey_add_document": "Brief hinzufügen",
"journey_add_interlude": "Zwischentext hinzufügen",
"journey_interlude_label": "Zwischentext",
"journey_item_pending_remove": "wird entfernt…",
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
"journey_title_aria_label": "Titel der Lesereise",
"journey_intro_aria_label": "Einleitung der Lesereise",
"journey_note_add": "Notiz hinzufügen",
"journey_note_remove": "Notiz entfernen",
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
"journey_already_added": "Bereits enthalten",
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
"journey_move_up": "'{title}' nach oben verschieben",
"journey_move_down": "'{title}' nach unten verschieben",
"journey_note_error": "Notiz konnte nicht gespeichert werden",
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
"journey_remove_item_aria": "'{title}' entfernen",
"journey_remove_confirm": "Wirklich entfernen?",
"journey_remove_confirm_yes": "Bestätigen",
"journey_remove_confirm_cancel": "Abbrechen",
"journey_mutation_error_reload": "Aktion fehlgeschlagen bitte Seite neu laden.",
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
"journey_intro_placeholder": "Einleitung (optional)",
"journey_interlude_placeholder": "Zwischentext eingeben…",
"journey_add_interlude_confirm": "Hinzufügen",
"journey_edit_title_story": "Geschichte bearbeiten",
"journey_edit_title_journey": "Lesereise bearbeiten",
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"person_unknown": "[Unbekannt]",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
}

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Type a name...",
"comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...",
"comp_typeahead_error": "Search failed. Please try again.",
"comp_typeahead_no_results": "No matches",
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
@@ -1023,6 +1025,10 @@
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
"error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
"journey_item_document_deleted": "[Document deleted]",
"geschichten_index_title": "Stories",
"geschichten_new_button": "New story",
"geschichten_filter_all_pill": "All",
@@ -1033,10 +1039,15 @@
"geschichten_empty_for_person": "No stories found for {name}.",
"geschichten_empty_for_persons": "No stories found for {names}.",
"geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_filter_document_chip": "Filtered by letter:",
"geschichten_filter_remove_document_chip": "Remove letter {title} from filter",
"geschichten_empty_for_document": "No stories reference this letter yet",
"geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}",
"journey_compiled_on": "compiled on {date}",
"geschichten_persons_section": "People in this story",
"geschichten_documents_section": "Referenced documents",
"geschichten_document_link_placeholder": "Open document",
"geschichten_card_heading": "Stories",
"geschichten_card_write_action": "+ Write a story",
"geschichten_card_attach_action": "+ Attach a story",
@@ -1044,6 +1055,7 @@
"geschichten_card_show_all": "Show all",
"geschichte_editor_title_placeholder": "Story title",
"geschichte_editor_body_placeholder": "Write your story here…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "DRAFT",
"geschichte_editor_status_published": "PUBLISHED",
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
@@ -1058,8 +1070,17 @@
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
"geschichte_editor_personen_heading": "People",
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
"geschichte_editor_dokumente_heading": "Documents",
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
"geschichte_documents_heading": "Letters & documents",
"geschichte_documents_hint": "Which documents belong to this story?",
"geschichte_documents_empty": "No documents linked yet. Search below for a letter to add it to this story.",
"geschichte_documents_picker_label": "Add document",
"geschichte_documents_picker_placeholder": "Search for a letter or document…",
"geschichte_documents_deleted_placeholder": "Document was deleted",
"geschichte_documents_remove_label": "Remove document: {title}",
"geschichte_documents_capacity": "This story has already reached the maximum of 100 documents.",
"geschichte_documents_duplicate": "This document is already linked to the story.",
"geschichte_documents_added_announce": "Added: {title}",
"geschichte_documents_removed_announce": "Removed: {title}",
"geschichte_editor_search_person": "Search person…",
"geschichte_editor_search_document": "Search document…",
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
@@ -1153,5 +1174,58 @@
"themen_alle": "All Topics",
"themen_leer": "No topics assigned yet.",
"themen_weitere": "+ {count} more",
"themen_dokumente": "{count} documents"
"themen_dokumente": "{count} documents",
"journey_badge_list": "JOURNEY",
"journey_badge_detail": "READING JOURNEY",
"journey_selector_question": "What would you like to create?",
"journey_selector_story_title": "Story",
"journey_selector_story_desc": "A narrative story with images and text.",
"journey_selector_journey_title": "Reading Journey",
"journey_selector_journey_desc": "A curated selection of letters with notes.",
"journey_selector_next_btn": "Continue",
"journey_placeholder_back": "different selection",
"journey_create_submit": "Create reading journey",
"journey_item_open_aria": "Open letter from {date}",
"journey_item_open_aria_undated": "Open letter",
"journey_item_open": "Open letter",
"journey_item_meta_from_to": "from {sender} to {receiver}",
"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.",
"journey_add_document": "Add letter",
"journey_add_interlude": "Add interlude",
"journey_interlude_label": "Interlude",
"journey_item_pending_remove": "removing…",
"journey_publish_disabled_hint": "A title and at least one entry are required.",
"journey_title_aria_label": "Title of the reading journey",
"journey_intro_aria_label": "Introduction of the reading journey",
"journey_note_add": "Add note",
"journey_note_remove": "Remove note",
"journey_note_save_hint": "Saved when you leave the field.",
"journey_intro_save_hint": "Saved when you click 'Save'.",
"journey_already_added": "Already included",
"journey_note_aria_label": "Curator note for {title}",
"journey_move_up": "Move '{title}' up",
"journey_move_down": "Move '{title}' down",
"journey_note_error": "Could not save note",
"journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}",
"journey_remove_item_aria": "Remove '{title}'",
"journey_remove_confirm": "Really remove?",
"journey_remove_confirm_yes": "Confirm",
"journey_remove_confirm_cancel": "Cancel",
"journey_mutation_error_reload": "Action failed please reload the page.",
"journey_published_empty_warning": "This journey will remain published without any entries.",
"journey_intro_placeholder": "Introduction (optional)",
"journey_interlude_placeholder": "Enter interlude text…",
"journey_add_interlude_confirm": "Add",
"journey_edit_title_story": "Edit story",
"journey_edit_title_journey": "Edit reading journey",
"journey_publish_disabled_title": "Title and at least one entry required",
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"person_unknown": "[Unknown]",
"error_journey_document_already_added": "This letter is already included in the reading journey.",
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
}

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Escriba un nombre...",
"comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...",
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
"comp_typeahead_no_results": "Sin resultados",
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
@@ -1023,6 +1025,10 @@
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
"error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
"journey_item_document_deleted": "[Documento eliminado]",
"geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia",
"geschichten_filter_all_pill": "Todas",
@@ -1033,10 +1039,15 @@
"geschichten_empty_for_person": "No hay historias para {name}.",
"geschichten_empty_for_persons": "No hay historias para {names}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_filter_document_chip": "Filtrado por carta:",
"geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro",
"geschichten_empty_for_document": "Aún no hay historias sobre esta carta",
"geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}",
"journey_compiled_on": "recopilada el {date}",
"geschichten_persons_section": "Personas en esta historia",
"geschichten_documents_section": "Documentos mencionados",
"geschichten_document_link_placeholder": "Abrir documento",
"geschichten_card_heading": "Historias",
"geschichten_card_write_action": "+ Escribir historia",
"geschichten_card_attach_action": "+ Adjuntar historia",
@@ -1044,6 +1055,7 @@
"geschichten_card_show_all": "Mostrar todas",
"geschichte_editor_title_placeholder": "Título de la historia",
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
"geschichte_sidebar_status": "Estado",
"geschichte_editor_status_draft": "BORRADOR",
"geschichte_editor_status_published": "PUBLICADA",
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
@@ -1058,8 +1070,17 @@
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
"geschichte_editor_personen_heading": "Personas",
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
"geschichte_editor_dokumente_heading": "Documentos",
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
"geschichte_documents_heading": "Cartas y documentos",
"geschichte_documents_hint": "¿Qué documentos pertenecen a esta historia?",
"geschichte_documents_empty": "Aún no hay documentos vinculados. Busca abajo una carta para añadirla a esta historia.",
"geschichte_documents_picker_label": "Añadir documento",
"geschichte_documents_picker_placeholder": "Buscar una carta o documento…",
"geschichte_documents_deleted_placeholder": "El documento fue eliminado",
"geschichte_documents_remove_label": "Quitar documento: {title}",
"geschichte_documents_capacity": "Esta historia ya ha alcanzado el número máximo de documentos (100).",
"geschichte_documents_duplicate": "Este documento ya está vinculado a la historia.",
"geschichte_documents_added_announce": "Añadido: {title}",
"geschichte_documents_removed_announce": "Quitado: {title}",
"geschichte_editor_search_person": "Buscar persona…",
"geschichte_editor_search_document": "Buscar documento…",
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
@@ -1153,5 +1174,58 @@
"themen_alle": "Todos los temas",
"themen_leer": "Aún no hay temas.",
"themen_weitere": "+ {count} más",
"themen_dokumente": "{count} documentos"
"themen_dokumente": "{count} documentos",
"journey_badge_list": "VIAJE",
"journey_badge_detail": "VIAJE DE LECTURA",
"journey_selector_question": "¿Qué deseas crear?",
"journey_selector_story_title": "Historia",
"journey_selector_story_desc": "Una historia narrada con imágenes y texto.",
"journey_selector_journey_title": "Viaje de lectura",
"journey_selector_journey_desc": "Una selección curada de cartas con notas.",
"journey_selector_next_btn": "Continuar",
"journey_placeholder_back": "otra selección",
"journey_create_submit": "Crear viaje de lectura",
"journey_item_open_aria": "Abrir carta del {date}",
"journey_item_open_aria_undated": "Abrir carta",
"journey_item_open": "Abrir carta",
"journey_item_meta_from_to": "de {sender} a {receiver}",
"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.",
"journey_add_document": "Añadir carta",
"journey_add_interlude": "Añadir interludio",
"journey_interlude_label": "Interludio",
"journey_item_pending_remove": "eliminando…",
"journey_publish_disabled_hint": "Se requieren un título y al menos una entrada.",
"journey_title_aria_label": "Título del viaje de lectura",
"journey_intro_aria_label": "Introducción del viaje de lectura",
"journey_note_add": "Añadir nota",
"journey_note_remove": "Eliminar nota",
"journey_note_save_hint": "Se guarda al salir del campo.",
"journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.",
"journey_already_added": "Ya incluido",
"journey_note_aria_label": "Nota del curador para {title}",
"journey_move_up": "Subir '{title}'",
"journey_move_down": "Bajar '{title}'",
"journey_note_error": "No se pudo guardar la nota",
"journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}",
"journey_remove_item_aria": "Eliminar '{title}'",
"journey_remove_confirm": "¿Realmente eliminar?",
"journey_remove_confirm_yes": "Confirmar",
"journey_remove_confirm_cancel": "Cancelar",
"journey_mutation_error_reload": "Acción fallida por favor recarga la página.",
"journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.",
"journey_intro_placeholder": "Introducción (opcional)",
"journey_interlude_placeholder": "Escribe el texto del interludio…",
"journey_add_interlude_confirm": "Añadir",
"journey_edit_title_story": "Editar historia",
"journey_edit_title_journey": "Editar viaje de lectura",
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"person_unknown": "[Desconocido]",
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
}

View File

@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
const docs = [makeDoc('d1', 'Only one')];
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /Alle 50/ })).toBeInTheDocument();
});
});

View File

@@ -1,21 +1,11 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
/**
* Exactly the fields this picker reads — id for selection/dedup, the rest for
* the honest date label. A full `Document` and a `DocumentListItem` are both
* structurally assignable, so the search results need no cast.
*/
type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
import {
createDocumentTypeahead,
formatDocumentOption,
type DocumentOption
} from './documentTypeahead';
interface Props {
selectedDocuments?: DocumentOption[];
@@ -30,13 +20,16 @@ let {
}: Props = $props();
let searchTerm = $state('');
let results: DocumentOption[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
const picker = createDocumentTypeahead();
// Filter out already-selected documents from typeahead results.
const filteredResults = $derived(
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
);
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs: DocumentOption[] = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}));
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
if (searchTerm.trim().length >= 1) {
picker.setQuery(searchTerm);
} else {
picker.close();
}
}
function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
results = [];
picker.close();
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
<input type="hidden" name={hiddenInputName} value={doc.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
<div
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
>
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
<span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
>
{formatDocLabel(doc)}
{formatDocumentOption(doc)}
<button
type="button"
onclick={() => removeDocument(doc.id)}
@@ -136,24 +94,23 @@ function formatDocLabel(doc: DocumentOption): string {
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => {
updateDropdownPosition();
showDropdown = true;
}}
onfocus={() => updateDropdownPosition()}
placeholder={placeholder}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
{#if picker.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else if picker.error}
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
{:else}
{#each results as doc (doc.id)}
{#each filteredResults as doc (doc.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectDocument(doc)}
@@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string {
role="button"
tabindex="0"
>
{formatDocLabel(doc)}
{formatDocumentOption(doc)}
</div>
{/each}
{/if}

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => {
});
});
describe('DocumentMultiSelect — search failure', () => {
it('shows an error row when the search request fails instead of looking like "no results"', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(m.comp_typeahead_error());
});
});
describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, {

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import {
createDocumentTypeahead,
formatDocumentOption,
type DocumentOption
} from './documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
placeholder?: string;
/** Set when a visible <label for> is wired externally — replaces the aria-label fallback. */
inputId?: string;
onSelect: (doc: DocumentOption) => void;
}
let {
alreadyAddedIds = new Set(),
placeholder = m.journey_add_document(),
inputId = undefined,
onSelect
}: Props = $props();
const uid = $props.id();
const listboxId = `doc-picker-listbox-${uid}`;
const resolvedInputId = $derived(inputId ?? `doc-picker-input-${uid}`);
const picker = createDocumentTypeahead();
let inputValue = $state('');
const activeOptionId = $derived(
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
? `${listboxId}-option-${picker.activeIndex}`
: undefined
);
function handleInput(e: Event) {
const q = (e.currentTarget as HTMLInputElement).value;
inputValue = q;
picker.setActiveIndex(-1);
if (q.trim().length >= 1) {
picker.setQuery(q);
} else {
picker.close();
}
}
function handleSelect(doc: DocumentOption) {
if (alreadyAddedIds.has(doc.id!)) return;
inputValue = '';
picker.close();
onSelect(doc);
}
function handleKeydown(e: KeyboardEvent) {
if (!picker.isOpen) return;
const results = picker.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const active = results[picker.activeIndex];
// handleSelect is a no-op for already-added (aria-disabled) options.
if (active) handleSelect(active);
} else if (e.key === 'Escape') {
e.preventDefault();
picker.close();
}
}
</script>
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
<input
type="text"
role="combobox"
autocomplete="off"
id={resolvedInputId}
aria-label={inputId ? undefined : placeholder}
aria-expanded={picker.isOpen}
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
? listboxId
: undefined}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
placeholder={placeholder}
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if picker.isOpen}
{#if picker.loading}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</p>
</div>
{:else if picker.error}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
</div>
{:else if picker.results.length === 0}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</p>
</div>
{:else}
<ul
id={listboxId}
role="listbox"
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
{#each picker.results as doc, i (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === picker.activeIndex}
aria-disabled={disabled}
onclick={() => handleSelect(doc)}
class={[
'px-3 py-2 text-ink select-none',
i === picker.activeIndex ? 'bg-muted' : '',
disabled
? 'cursor-default opacity-50'
: 'cursor-pointer hover:bg-muted'
].join(' ')}
>
{formatDocumentOption(doc)}
{#if disabled}
<span class="sr-only">{m.journey_already_added()}</span>
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY' as const,
metaDateEnd: undefined
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentPickerDropdown — empty query guard', () => {
it('does not call fetch on empty query', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), '');
await waitForDebounce();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — already-added indicator', () => {
it('shows already-added document as aria-disabled with sr-only hint', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect: vi.fn()
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const disabledItem = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
// Screen-reader text "bereits enthalten" must be present in the item
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — selection', () => {
it('calls onSelect with the item when a non-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/i));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not call onSelect when an aria-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await page.getByText(/Brief von Eugenie/i).click({ force: true });
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — keyboard navigation', () => {
it('selects the first option via ArrowDown then Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not select an aria-disabled option on Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).not.toHaveBeenCalled();
});
it('closes the dropdown on Escape', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('points aria-activedescendant at the active option', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox');
await userEvent.fill(input, 'Brief');
await waitForDebounce();
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
await userEvent.keyboard('{ArrowDown}');
const activeId = input.element().getAttribute('aria-activedescendant');
expect(activeId).toMatch(/-option-0$/);
const firstOption = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(firstOption.id).toBe(activeId);
expect(firstOption.getAttribute('aria-selected')).toBe('true');
});
});
describe('DocumentPickerDropdown — no results', () => {
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — ARIA listbox integrity', () => {
it('does not render a listbox when results are empty (no aria-required-children violation)', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
// no-results message must be visible, but NOT inside a listbox
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('does not render a listbox when loading (no aria-required-children violation)', async () => {
let resolveSearch!: (v: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve)))
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
// While in-flight, no listbox should exist
expect(document.querySelector('[role="listbox"]')).toBeNull();
resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) });
});
it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => {
mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const options = document.querySelectorAll('[role="listbox"] [role="option"]');
expect(options.length).toBeGreaterThan(0);
options.forEach((opt) => {
expect(opt).not.toHaveAttribute('tabindex');
});
});
});
describe('DocumentPickerDropdown — external label wiring (#795)', () => {
it('renders a generated default id on the input and keeps the aria-label fallback', async () => {
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox').element() as HTMLInputElement;
expect(input.id).toMatch(/^doc-picker-input-/);
expect(input.getAttribute('aria-label')).not.toBeNull();
});
it('uses the provided inputId and drops the aria-label so an external label wins', async () => {
render(DocumentPickerDropdown, { onSelect: vi.fn(), inputId: 'story-doc-picker' });
const input = page.getByRole('combobox').element() as HTMLInputElement;
expect(input.id).toBe('story-doc-picker');
expect(input.getAttribute('aria-label')).toBeNull();
});
});

View File

@@ -0,0 +1,45 @@
import type { components } from '$lib/generated/api';
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
export type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
export function createDocumentTypeahead() {
return createTypeahead<DocumentOption>({
fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => {
// Without this check a 401/500 parses as JSON without `items` and
// renders as "no results" — errors must reach the hook's error state.
if (!r.ok) throw new Error(`document search failed: ${r.status}`);
return r.json();
})
.then((b: { items: DocumentListItem[] }) =>
b.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}))
)
});
}
export function formatDocumentOption(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}

View File

@@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte';
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { csrfFetch } from '$lib/shared/cookies';
type Props = {

View File

@@ -84,6 +84,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items/reorder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Reorder journey items
* @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request.
*/
put: operations["reorderItems"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}": {
parameters: {
query?: never;
@@ -420,6 +440,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["appendItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents": {
parameters: {
query?: never;
@@ -804,6 +840,22 @@ export interface paths {
patch: operations["update"];
trace?: never;
};
"/api/geschichten/{id}/items/{itemId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteItem"];
options?: never;
head?: never;
patch: operations["updateItemNote"];
trace?: never;
};
"/api/documents/{id}/training-labels": {
parameters: {
query?: never;
@@ -875,7 +927,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["search_1"];
get: operations["search"];
put?: never;
post?: never;
delete?: never;
@@ -1339,7 +1391,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["search_2"];
get: operations["search_1"];
put?: never;
post?: never;
delete?: never;
@@ -1690,6 +1742,32 @@ export interface components {
provisional: boolean;
readonly displayName: string;
};
JourneyReorderDTO: {
itemIds?: string[];
};
DocumentSummary: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
/** Format: date */
documentDateEnd?: string;
/** @enum {string} */
datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
senderName?: string;
receiverName?: string;
/** Format: int32 */
receiverCount: number;
};
JourneyItemView: {
/** Format: uuid */
id: string;
/** Format: int32 */
position: number;
document?: components["schemas"]["DocumentSummary"];
note?: string;
};
DocumentUpdateDTO: {
title?: string;
/** Format: date */
@@ -1819,75 +1897,6 @@ export interface components {
/** Format: uuid */
targetId: string;
};
Pageable: {
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
sort?: string[];
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
/** @enum {string} */
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
metaDateEnd?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
@@ -2015,25 +2024,44 @@ export interface components {
body?: string;
/** @enum {string} */
status?: "DRAFT" | "PUBLISHED";
/** @enum {string} */
type?: "STORY" | "JOURNEY";
personIds?: string[];
documentIds?: string[];
};
Geschichte: {
AuthorView: {
/** Format: uuid */
id: string;
displayName: string;
};
GeschichteView: {
/** Format: uuid */
id: string;
title: string;
body?: string;
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
author?: components["schemas"]["AppUser"];
persons?: components["schemas"]["Person"][];
documents?: components["schemas"]["Document"][];
/** @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;
/** Format: date-time */
publishedAt?: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
JourneyItemCreateDTO: {
/** Format: uuid */
documentId?: string;
note?: string;
};
CreateTranscriptionBlockDTO: {
/** Format: int32 */
@@ -2233,6 +2261,9 @@ export interface components {
actorName?: string;
documentTitle?: string;
};
JourneyItemUpdateDTO: {
note?: string;
};
TrainingLabelRequest: {
label?: string;
enrolled?: boolean;
@@ -2273,6 +2304,11 @@ export interface components {
/** Format: int64 */
transcriptionCount: number;
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
@@ -2335,13 +2371,13 @@ export interface components {
lastName?: string;
/** Format: int64 */
documentCount?: number;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
provisional?: boolean;
alias?: string;
personType?: string;
familyMember?: boolean;
};
@@ -2440,6 +2476,8 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -2448,8 +2486,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -2472,6 +2508,25 @@ export interface components {
nodes: components["schemas"]["PersonNodeDTO"][];
edges: components["schemas"]["RelationshipDTO"][];
};
AuthorSummary: {
firstName?: string;
lastName?: string;
};
GeschichteSummary: {
body?: string;
title: string;
/** Format: uuid */
id: string;
/** @enum {string} */
type: "STORY" | "JOURNEY";
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
/** Format: date-time */
updatedAt: string;
author?: components["schemas"]["AuthorSummary"];
/** Format: date-time */
publishedAt?: string;
};
DocumentVersionSummary: {
/** Format: uuid */
id: string;
@@ -2513,6 +2568,63 @@ export interface components {
/** Format: int32 */
totalPages?: number;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
/** @enum {string} */
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
metaDateEnd?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
IncompleteDocumentDTO: {
/** Format: uuid */
id: string;
@@ -2561,7 +2673,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -2871,6 +2983,32 @@ export interface operations {
};
};
};
reorderItems: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyReorderDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"][];
};
};
};
};
getDocument: {
parameters: {
query?: never;
@@ -3574,9 +3712,13 @@ export interface operations {
list: {
parameters: {
query?: {
/** @description Filter by status. Callers without BLOG_WRITE always receive PUBLISHED results regardless of the value passed. Callers with BLOG_WRITE requesting DRAFT receive only their own unpublished stories. */
status?: "DRAFT" | "PUBLISHED";
/** @description AND-filter: story must include all supplied person IDs. */
personId?: string[];
/** @description Filter to stories containing this document. */
documentId?: string;
/** @description Maximum results to return. Values ≤ 0 default to 50. Clamped at 200. */
limit?: number;
};
header?: never;
@@ -3591,7 +3733,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"][];
"*/*": components["schemas"]["GeschichteSummary"][];
};
};
};
@@ -3615,7 +3757,33 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
};
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"];
};
};
};
@@ -4291,7 +4459,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
@@ -4337,7 +4505,55 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
};
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"];
};
};
};
@@ -4486,7 +4702,7 @@ export interface operations {
};
};
};
search_1: {
search: {
parameters: {
query?: {
q?: string;
@@ -5110,7 +5326,7 @@ export interface operations {
};
};
};
search_2: {
search_1: {
parameters: {
query?: {
q?: string;
@@ -5325,7 +5541,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
};
header?: never;
path?: never;

View File

@@ -5,34 +5,26 @@ import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteView = components['schemas']['GeschichteView'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
geschichte?: GeschichteView | null;
initialPersons?: Person[];
initialDocuments?: Document[];
/** Must reject when the save failed — the editor keeps its dirty state then. */
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
@@ -41,11 +33,8 @@ let {
let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
let selectedPersons: PersonOption[] = $state(
geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons
);
let dirty = $state(false);
@@ -118,14 +107,17 @@ function handleTitleInput() {
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
});
dirty = false;
try {
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
});
dirty = false;
} catch {
// onSubmit signalled failure — keep dirty so the unsaved guard stays armed
}
}
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
@@ -148,6 +140,7 @@ function exec(action: () => void) {
<input
type="text"
bind:value={title}
maxlength="255"
oninput={handleTitleInput}
onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()}
@@ -241,43 +234,12 @@ function exec(action: () => void) {
</div>
<!-- Sidebar -->
<aside class="flex flex-col gap-6">
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft
? m.geschichte_editor_status_draft()
: m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
<GeschichteSidebar
status={status}
bind:selectedPersons={selectedPersons}
geschichteId={geschichte?.id}
items={geschichte?.items ?? []}
/>
</div>
<!-- Save bar -->

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from './GeschichteEditor.svelte';
const personFactory = (id: string, displayName: string) => ({
@@ -8,19 +9,9 @@ const personFactory = (id: string, displayName: string) => ({
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const
});
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED' as const,
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
personType: 'PERSON' as const,
familyMember: false,
provisional: false
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
@@ -28,8 +19,9 @@ const draftFactory = (overrides: Record<string, unknown> = {}) => ({
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
documents: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
@@ -63,6 +55,22 @@ describe('GeschichteEditor — title-required guard', () => {
});
});
describe('GeschichteEditor — onSubmit rejects on failure', () => {
it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => {
// Contract: onSubmit rejects on failure. Without the catch in save(), this
// click would surface as an unhandled promise rejection and fail the run.
const onSubmit = vi.fn().mockRejectedValue(new Error('save failed'));
render(GeschichteEditor, { geschichte: draftFactory(), onSubmit });
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// Editor still functional — a second save attempt goes through
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
});
});
describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
@@ -93,14 +101,6 @@ describe('GeschichteEditor — pre-fill', () => {
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('renders initial documents as chips', async () => {
render(GeschichteEditor, {
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
onSubmit: vi.fn()
});
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
@@ -154,11 +154,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
it('passes personIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
initialDocuments: [docFactory('d1', 'Brief A')],
onSubmit
});
@@ -171,6 +170,38 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
expect(payload.documentIds).toEqual(['d1']);
});
});
describe('GeschichteEditor — story document panel (#795)', () => {
it('shows the document panel with the story items when editing an existing story', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({
items: [
{
id: 'i1',
position: 10,
document: {
id: 'd1',
title: 'Brief von Eugenie',
datePrecision: 'DAY' as const,
receiverCount: 0
}
}
]
}),
onSubmit: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
.toBeInTheDocument();
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
});
it('hides the document panel when no geschichte is set (creation flow)', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
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="group flex min-h-[44px] transition-colors hover:bg-canvas/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<!-- Meta column (desktop) -->
<div class="hidden w-40 shrink-0 flex-col items-start gap-1 border-r border-line-2 p-3 sm:flex">
<span
aria-hidden="true"
class="flex h-7 w-7 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white"
style="background-color: {personAvatarColor(authorName)}"
>
{getInitials(authorName)}
</span>
<span class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</span>
{#if publishedAt}
<span class="font-sans text-sm text-ink-3">{publishedAt}</span>
{/if}
{#if isJourney}
<span
data-testid="journey-badge"
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase"
>
{m.journey_badge_list()}
</span>
{/if}
</div>
<!-- Content column -->
<div class="min-w-0 flex-1 p-3 sm:px-4">
<!-- Compact meta line (mobile only) -->
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
<span
aria-hidden="true"
class="h-2.5 w-2.5 shrink-0 rounded-full"
style="background-color: {personAvatarColor(authorName)}"
></span>
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
{#if publishedAt}
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
{/if}
</div>
<div class="mb-1 flex items-center gap-1.5">
<h2 class="font-serif text-lg leading-snug text-ink group-hover:underline">
{geschichte.title}
</h2>
{#if isJourney}
<span
data-testid="journey-badge-mobile"
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase sm:hidden"
>
{m.journey_badge_list()}
</span>
{/if}
</div>
{#if geschichte.body}
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<p class="line-clamp-2 font-sans text-sm leading-relaxed text-ink-3">
{plainExcerpt(geschichte.body, 150)}
</p>
{/if}
</div>
</a>

View File

@@ -0,0 +1,94 @@
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' },
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('row text sizes suit the full-width list: title text-lg, excerpt/meta text-sm (#802)', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
const title = document.querySelector('h2');
expect(title!.className).toContain('text-lg');
expect(title!.className).not.toContain('text-[15px]');
const excerpt = document.querySelector('p');
expect(excerpt!.className).toContain('text-sm');
expect(excerpt!.className).not.toContain('text-xs');
const meta = Array.from(document.querySelectorAll('span')).filter((s) =>
s.textContent?.includes('Anna Schmidt')
);
expect(meta.length).toBeGreaterThan(0);
for (const span of meta) {
expect(span.className).toContain('text-sm');
}
});
it('desktop meta column is wide enough for text-sm names (w-40, #802)', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
const metaColumn = document.querySelector('[class*="border-r"]');
expect(metaColumn).not.toBeNull();
expect(metaColumn!.className).toContain('w-40');
expect(metaColumn!.className).not.toContain('w-28');
});
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 uses the 12px label size — text-xs is the visible-text floor', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge!.className).toContain('text-xs');
// 10px was below the house floor for the 60+ audience (round-3 review)
expect(badge!.className).not.toContain('text-[10px]');
});
it('renders author name in meta line', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
expect(document.body.textContent).toContain('Anna Schmidt');
});
});

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import type { PersonOption } from '$lib/person/personOption';
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
status: 'DRAFT' | 'PUBLISHED';
selectedPersons: PersonOption[];
/** When set, the story document panel is rendered (STORY edit only). */
geschichteId?: string;
items?: JourneyItemView[];
}
let {
status,
selectedPersons = $bindable(),
geschichteId = undefined,
items = []
}: Props = $props();
const isDraft = $derived(status === 'DRAFT');
</script>
<aside class="flex flex-col gap-6">
<!-- Status section -->
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_sidebar_status()}
</summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<!-- hidden below sm: the <summary> already shows this label there -->
<h2
class="mb-1 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_sidebar_status()}
</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft ? m.geschichte_editor_status_draft() : m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
</details>
<!-- Persons section -->
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_editor_personen_heading()}
</summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
</details>
<!-- Documents section — STORY edit only; journeys manage items in the editor column -->
{#if geschichteId}
<StoryDocumentPanel geschichteId={geschichteId} items={items} />
{/if}
</aside>

View File

@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import GeschichteSidebar from './GeschichteSidebar.svelte';
const item = {
id: 'i1',
position: 10,
document: {
id: 'd1',
title: 'Brief von Eugenie',
datePrecision: 'DAY' as const,
receiverCount: 0
}
};
afterEach(() => cleanup());
describe('GeschichteSidebar — document panel contract (#795)', () => {
it('renders the document panel when geschichteId and items are provided', async () => {
render(GeschichteSidebar, {
status: 'DRAFT',
selectedPersons: [],
geschichteId: 'g1',
items: [item]
});
await expect
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
.toBeInTheDocument();
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
});
it('does not render the document panel without geschichteId', async () => {
render(GeschichteSidebar, { status: 'DRAFT', selectedPersons: [] });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});

View File

@@ -3,11 +3,12 @@ import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorName } from './utils';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
geschichten: Geschichte[];
geschichten: GeschichteSummary[];
personId: string;
personName: string;
canWrite: boolean;
@@ -18,16 +19,13 @@ let { geschichten, personId, personName, canWrite }: Props = $props();
const visible = $derived(geschichten.slice(0, 3));
const hasOverflow = $derived(geschichten.length >= 3);
function formatPublishedDate(g: Geschichte): string | null {
function formatPublishedDate(g: GeschichteSummary): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
function authorName(g: Geschichte): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
function authorName(g: GeschichteSummary): string {
return formatAuthorName(g.author);
}
</script>

View File

@@ -3,19 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
const makeStory = (id: string, title: string, body: string | undefined = '<p>Body</p>') => ({
id,
title,
body,
status: 'PUBLISHED' as const,
type: 'STORY' as const,
publishedAt: '2024-04-01T12:00:00',
createdAt: '2024-03-01T12:00:00',
updatedAt: '2024-04-01T12:00:00',
persons: [],
documents: [],
items: [],
author: {
id: 'u1',
email: 'marcel@example.com',
firstName: 'Marcel',
lastName: 'Raddatz',
enabled: true,
@@ -120,6 +120,16 @@ describe('GeschichtenCard', () => {
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
render(GeschichtenCard, {
geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [

View File

@@ -2,20 +2,29 @@ import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
import type { components } from '$lib/generated/api';
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(cleanup);
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
publishedAt: '2026-04-15T10:00:00Z',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
...overrides
});
const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSummary =>
({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
status: 'PUBLISHED' as const,
type: 'STORY' as const,
publishedAt: '2026-04-15T10:00:00Z',
author: {
firstName: 'Anna',
lastName: 'Schmidt'
},
...overrides
}) as GeschichteSummary;
const baseProps = (overrides: Record<string, unknown> = {}) => ({
geschichten: [] as ReturnType<typeof makeGeschichte>[],
geschichten: [] as GeschichteSummary[],
personId: 'p-1',
personName: 'Anna Schmidt',
canWrite: false,
@@ -93,17 +102,17 @@ describe('GeschichtenCard', () => {
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
});
it('falls back to author email when no name', async () => {
it('falls back to [Unbekannt] when no name', async () => {
render(GeschichtenCard, {
props: baseProps({
geschichten: [
makeGeschichte({
author: { firstName: undefined, lastName: undefined, email: 'fallback@x' }
author: { firstName: undefined, lastName: undefined }
})
]
})
});
await expect.element(page.getByText(/fallback@x/)).toBeVisible();
await expect.element(page.getByText('[Unbekannt]')).toBeVisible();
});
});

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
onAddDocument: (doc: DocumentOption) => void;
onAddInterlude: (text: string) => void;
}
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
let showPicker = $state(false);
let showInterludeForm = $state(false);
let interludeDraft = $state('');
let rootEl: HTMLElement | null = $state(null);
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
async function togglePicker() {
showPicker = !showPicker;
showInterludeForm = false;
if (showPicker) {
// Keyboard users need a perceivable result of activating the toggle.
await tick();
rootEl?.querySelector<HTMLInputElement>('#journey-add-picker input')?.focus();
}
}
async function toggleInterludeForm() {
showInterludeForm = !showInterludeForm;
showPicker = false;
if (showInterludeForm) {
await tick();
rootEl?.querySelector<HTMLTextAreaElement>('#journey-add-interlude textarea')?.focus();
}
}
function handleDocumentSelect(doc: DocumentOption) {
showPicker = false;
onAddDocument(doc);
}
function handleInterludeConfirm() {
if (!canConfirmInterlude) return;
const text = interludeDraft.trim();
interludeDraft = '';
showInterludeForm = false;
onAddInterlude(text);
}
function handleInterludeCancel() {
interludeDraft = '';
showInterludeForm = false;
}
</script>
<div bind:this={rootEl} class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
type="button"
data-add-document
onclick={togglePicker}
aria-expanded={showPicker}
aria-controls={showPicker ? 'journey-add-picker' : undefined}
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.journey_add_document()}
</button>
<button
type="button"
onclick={toggleInterludeForm}
aria-expanded={showInterludeForm}
aria-controls={showInterludeForm ? 'journey-add-interlude' : undefined}
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.journey_add_interlude()}
</button>
</div>
{#if showPicker}
<div id="journey-add-picker">
<DocumentPickerDropdown
alreadyAddedIds={alreadyAddedIds}
onSelect={handleDocumentSelect}
placeholder={m.journey_add_document()}
/>
</div>
{/if}
{#if showInterludeForm}
<div id="journey-add-interlude" class="flex flex-col gap-2">
<textarea
bind:value={interludeDraft}
placeholder={m.journey_interlude_placeholder()}
rows={3}
maxlength={2000}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<div class="flex gap-2">
<button
type="button"
onclick={handleInterludeConfirm}
disabled={!canConfirmInterlude}
class={[
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
canConfirmInterlude
? 'bg-primary text-primary-fg hover:opacity-90'
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
].join(' ')}
>
{m.journey_add_interlude_confirm()}
</button>
<button
type="button"
onclick={handleInterludeCancel}
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.journey_remove_confirm_cancel()}
</button>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyAddBar from './JourneyAddBar.svelte';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyAddBar — interlude flow', () => {
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeDisabled();
});
it('confirm becomes enabled after typing text', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeEnabled();
});
it('calls onAddInterlude with text on confirm', async () => {
const onAddInterlude = vi.fn();
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
});
it('limits the interlude textarea to 2000 characters', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000');
});
});
describe('JourneyAddBar — document picker', () => {
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
);
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_document()));
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,408 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { getErrorMessage } from '$lib/shared/errors';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
import GeschichteSidebar from './GeschichteSidebar.svelte';
import JourneyItemRow from './JourneyItemRow.svelte';
import JourneyAddBar from './JourneyAddBar.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
geschichte: GeschichteView;
/** Must reject when the save failed — the editor keeps its dirty state then. */
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let { geschichte, onSubmit, submitting = false }: Props = $props();
const unsaved = createUnsavedWarning();
let title = $state(geschichte.title ?? '');
let body = $state(geschichte.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
let selectedPersons: PersonOption[] = $state(
geschichte.persons ? Array.from(geschichte.persons).map(toPersonOption) : []
);
let items: JourneyItemView[] = $state(
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
);
let titleTouched = $state(false);
let mutationError = $state('');
let pendingRemoveIds: string[] = $state([]);
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleAnnounceReset() {
if (announceTimer) clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveAnnounce = '';
announceTimer = null;
}, 500);
}
const titleEmpty = $derived(title.trim().length === 0);
const showTitleError = $derived(titleEmpty && titleTouched);
const isDraft = $derived(status === 'DRAFT');
const alreadyAddedIds = $derived(
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
const canPublish = $derived(items.length > 0 && !titleEmpty);
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
// Skip the initial run so mounting with pre-existing persons doesn't mark dirty.
let _personEffectMounted = false;
$effect(() => {
void selectedPersons.length;
if (!_personEffectMounted) {
_personEffectMounted = true;
return;
}
unsaved.markDirty();
});
let listEl: HTMLElement | null = $state(null);
let editorColEl: HTMLElement | null = $state(null);
const dragDrop = createBlockDragDrop<JourneyItemView>({
getSortedBlocks: () => items,
onReorder: handleReorder
});
$effect(() => {
dragDrop.setListElement(listEl);
});
/** Maps a failed mutation response to a user-facing message via its backend error code. */
async function failureMessage(res: Response): Promise<string> {
const code = (await res.json().catch(() => ({})))?.code;
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}
/** Moves keyboard focus to a control inside the row of the given item. */
async function focusRowControl(itemId: string, selector: string) {
await tick();
editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
}
async function handleReorder(itemIds: string[]) {
const prev = [...items];
items = itemIds.map((id) => items.find((i) => i.id === id)!);
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds })
});
if (!res.ok) {
items = prev;
mutationError = await failureMessage(res);
return;
}
const updated: JourneyItemView[] = await res.json();
items = updated.sort((a, b) => a.position - b.position);
} catch (e) {
console.error('Journey reorder failed', e);
items = prev;
mutationError = m.journey_mutation_error_reload();
}
}
/** Pessimistic append shared by both add paths — items update only on API success. */
async function appendItem(body: { documentId?: string; note?: string }) {
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
// Move-up is disabled on the first row — fall back to the remove button then.
await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
} catch (e) {
console.error('Journey item append failed', e);
mutationError = m.journey_mutation_error_reload();
}
}
async function handleAddDocument(doc: DocumentOption) {
await appendItem({ documentId: doc.id });
}
async function handleAddInterlude(text: string) {
await appendItem({ note: text });
}
async function handleRemove(itemId: string) {
const idx = items.findIndex((i) => i.id === itemId);
mutationError = '';
pendingRemoveIds = [...pendingRemoveIds, itemId];
liveAnnounce = m.journey_item_pending_remove();
scheduleAnnounceReset();
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'DELETE'
});
if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
items = items.filter((i) => i.id !== itemId);
await tick();
if (items.length === 0 || idx <= 0) {
editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
} else {
await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
}
} catch (e) {
console.error('Journey item remove failed', e);
mutationError = m.journey_mutation_error_reload();
} finally {
pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
}
}
async function handleNotePatch(itemId: string, note: string | null) {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: note })
});
// Carry the backend error code's message so the row can show the specific
// reason (e.g. JOURNEY_NOTE_TOO_LONG) instead of a generic alert.
if (!res.ok) throw new Error(await failureMessage(res));
const updated: JourneyItemView = await res.json();
items = items.map((i) => (i.id === itemId ? updated : i));
}
async function handleMoveUp(index: number) {
if (index === 0) return;
const total = items.length;
const ids = items.map((i) => i.id);
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
await handleReorder(ids);
// Announce only after the server confirmed (or rejected) the reorder —
// announcing beforehand would claim success for a move that rolled back.
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index });
scheduleAnnounceReset();
}
async function handleMoveDown(index: number) {
if (index === items.length - 1) return;
const total = items.length;
const ids = items.map((i) => i.id);
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
await handleReorder(ids);
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
scheduleAnnounceReset();
}
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
try {
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
});
unsaved.clearOnSuccess();
} catch {
// onSubmit signalled failure — keep dirty flag so the banner stays
}
}
</script>
<!-- Screen-reader live region for move announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div bind:this={editorColEl} class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
type="text"
bind:value={title}
maxlength="255"
oninput={() => unsaved.markDirty()}
onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'journey-title-error' : undefined}
class="block w-full rounded border {showTitleError
? 'border-danger'
: 'border-line'} bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if showTitleError}
<p id="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Intro textarea -->
<div>
<textarea
bind:value={body}
maxlength="4000"
oninput={() => unsaved.markDirty()}
placeholder={m.journey_intro_placeholder()}
aria-label={m.journey_intro_aria_label()}
rows={3}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
</div>
<!-- Item list -->
{#if showPublishedEmptyWarning}
<p
class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
>
{m.journey_published_empty_warning()}
</p>
{/if}
{#if mutationError}
<p
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
role="alert"
>
{mutationError}
</p>
{/if}
{#if items.length === 0}
<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
{:else}
<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
<ol
bind:this={listEl}
onpointermove={(e) => dragDrop.handlePointerMove(e)}
onpointerup={() => dragDrop.handlePointerUp()}
class="m-0 flex list-none flex-col gap-2 p-0"
>
{#each items as item, i (item.id)}
<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
<li
data-block-wrapper
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
style={dragDrop.draggedBlockId === item.id
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
: ''}
>
{#if dragDrop.dropTargetIdx === i}
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
{/if}
<JourneyItemRow
item={item}
index={i}
total={items.length}
pendingRemove={pendingRemoveIds.includes(item.id)}
onMoveUp={() => handleMoveUp(i)}
onMoveDown={() => handleMoveDown(i)}
onRemove={() => handleRemove(item.id)}
onNotePatch={(note) => handleNotePatch(item.id, note)}
/>
</li>
{/each}
</ol>
{/if}
<JourneyAddBar
alreadyAddedIds={alreadyAddedIds}
onAddDocument={handleAddDocument}
onAddInterlude={handleAddInterlude}
/>
</div>
<!-- Sidebar -->
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
<p class="font-sans text-xs text-ink-3">
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
</p>
<div class="flex flex-col items-start gap-1 sm:items-end">
<div class="flex gap-2">
{#if isDraft}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting || titleEmpty}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save_draft()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || !canPublish}
title={canPublish ? undefined : m.journey_publish_disabled_title()}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_publish()}
</button>
{:else}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-[var(--color-warning-text)] hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_unpublish()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save()}
</button>
{/if}
</div>
{#if isDraft && !canPublish}
<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,813 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyEditor from './JourneyEditor.svelte';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate } from '$app/navigation';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const,
receiverCount: 0
});
/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Briefe der Familie Raddatz',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
geschichte: makeGeschichte(),
onSubmit: vi.fn().mockResolvedValue(undefined),
submitting: false,
...overrides
});
function mockCsrfFetch(responseFactory: () => object) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(responseFactory())
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyEditor — empty state', () => {
it('renders title input and intro textarea', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
});
it('labels the title input and intro textarea for screen readers', async () => {
render(JourneyEditor, defaultProps());
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
await expect
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
.toBeInTheDocument();
});
it('shows empty state message when items list is empty', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
});
});
describe('JourneyEditor — items in position order', () => {
it('renders items sorted by position', async () => {
const items = [
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
];
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe('JourneyEditor — publish surface', () => {
it('publish button disabled when no items', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
});
it('shows a visible hint while publishing is disabled', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
});
it('publish stays disabled until title is non-empty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
title: '',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
const titleInput = page.getByPlaceholder(/Titel/);
await userEvent.fill(titleInput, 'Meine Reise');
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
it('adding an item enables the publish button (canPublish becomes true)', async () => {
const newItem = { id: 'i1', position: 0, note: 'Test' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
// Publish should be disabled before adding item
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
// Add interlude
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// After item add, publish becomes enabled — item was added and state is correct
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: ' Meine Reise ',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
);
});
});
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
status: 'PUBLISHED',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
});
});
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
render(
JourneyEditor,
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
);
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
});
});
describe('JourneyEditor — add document', () => {
it('calls POST with documentId when document selected from picker', async () => {
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// dropdown option appears after the typeahead debounce
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
await userEvent.click(page.getByText(/Brief von Karl ·/));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({ method: 'POST' })
);
});
});
});
describe('JourneyEditor — add interlude', () => {
it('calls POST with note on interlude confirm', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
it('moves keyboard focus into the new row after the interlude is added', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
});
});
});
describe('JourneyEditor — mutation error code routing', () => {
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await expect
.element(page.getByText(m.error_journey_document_already_added()))
.toBeInTheDocument();
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
});
});
describe('JourneyEditor — remove with pending state', () => {
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
let resolveFetch!: (value: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
// Row still present, marked as pending (text appears in the row AND the live region,
// so scope the query to the row instead of using a page-wide locator)
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
await vi.waitFor(() => {
const row = document.querySelector('[data-block-id="i1"]');
expect(row).toBeTruthy();
expect(row!.textContent).toContain(m.journey_item_pending_remove());
expect(row!.className).toContain('opacity-60');
});
resolveFetch({ ok: true });
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('removes the row on successful DELETE', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('focuses a sensible target after a successful remove (not body)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
await vi.waitFor(() => {
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
});
});
});
describe('JourneyEditor — reorder via move buttons', () => {
it('move-up calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
it('move-down calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
it('renders the server-confirmed order after a successful reorder', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
const briefB = page.getByText('Brief B').element();
const briefA = page.getByText('Brief A').element();
expect(
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
});
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('JourneyEditor — live announce region', () => {
it('announces the move only after the reorder resolved, then clears', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
});
await vi.waitFor(
() => {
expect((liveRegion?.textContent ?? '').trim()).toBe('');
},
{ timeout: 2000 }
);
});
it('announces the error text instead of a success message when the move fails', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
});
});
});
describe('JourneyEditor — note patch body', () => {
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi
.fn()
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ note: null })
})
);
});
});
});
describe('JourneyEditor — duplicate document aria-disabled', () => {
it('already-added document appears as aria-disabled in picker', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
const option = page
.getByText(/Brief von Karl ·/)
.element()
.closest('li')!;
expect(option.getAttribute('aria-disabled')).toBe('true');
});
});
describe('JourneyEditor — unsaved warning banner', () => {
function triggerNavigationAttempt() {
const calls = vi.mocked(beforeNavigate).mock.calls;
if (calls.length === 0) return;
const [callback] = calls[calls.length - 1];
const cancel = vi.fn();
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
cancel,
to: { url: new URL('http://localhost/geschichten') }
});
return cancel;
}
it('banner is absent before any edit or navigation attempt', async () => {
render(JourneyEditor, defaultProps());
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('banner appears when dirty and a navigation is attempted', async () => {
render(JourneyEditor, defaultProps());
// Mark dirty by editing the title
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Simulate the user trying to navigate away
const cancel = triggerNavigationAttempt();
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
});
it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: 'Titel',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Trigger navigation → banner appears
triggerNavigationAttempt();
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
// Attempt save — onSubmit throws
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
// Banner must still be visible (isDirty was not cleared)
await vi.waitFor(() => {
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
});
});
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
// Regression guard for clearOnSuccess(): without it, a curator who edits the
// title and saves successfully stays trapped — the page goto() gets cancelled
// by the still-armed guard and the banner appears after a *successful* save.
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(JourneyEditor, defaultProps({ onSubmit }));
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Dirty state blocks navigation
expect(triggerNavigationAttempt()).toHaveBeenCalled();
// Save succeeds
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
// Guard is disarmed again — navigation passes and no banner shows
await vi.waitFor(() => {
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// The new interlude row renders its note textarea once the POST resolved
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// The item was persisted by its own POST — navigating away loses nothing
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
});
describe('JourneyEditor — selectedPersons marks dirty', () => {
function getNavCallback() {
const calls = vi.mocked(beforeNavigate).mock.calls;
const [callback] = calls[calls.length - 1];
return (cancel = vi.fn()) => {
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
cancel,
to: { url: new URL('http://localhost/geschichten') }
});
return cancel;
};
}
it('removing a person chip marks the editor dirty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
})
})
);
// Confirm navigation is NOT blocked initially (clean state)
const triggerNav = getNavCallback();
expect(triggerNav()).not.toHaveBeenCalled();
// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));
// After person removal, navigation should be blocked
await vi.waitFor(() => {
const cancel = triggerNav();
expect(cancel).toHaveBeenCalled();
});
});
});
describe('JourneyEditor — person chips from GeschichteView', () => {
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
})
})
);
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
<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-4 rounded-r-sm border-l-2 border-journey-border bg-journey-tint py-2 pr-3 pl-3"
>
<!-- plaintext — do NOT use {@html} here -->
<p class="text-base leading-relaxed text-ink italic">{note}</p>
</div>

View File

@@ -0,0 +1,64 @@
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('uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
const block = document.querySelector('[role="note"]');
expect(block!.className).toContain('bg-journey-tint');
expect(block!.className).toContain('border-journey-border');
expect(block!.className).not.toContain('bg-orange-50');
});
it('note text uses readable body size (text-base, #800)', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
const text = document.querySelector('[role="note"] p');
expect(text!.className).toContain('text-base');
expect(text!.className).not.toContain('text-xs');
});
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=');
});
});

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/shared/utils/date';
import { formatDocumentMetaLine } from './utils';
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 metaLine = $derived(formatDocumentMetaLine(doc));
const openAriaLabel = $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>
<div class="mb-3">
<div class="rounded-sm border border-line bg-surface p-3">
<!-- plaintext — do NOT use {@html} here -->
<p class="mb-0.5 font-serif text-base leading-snug text-ink">{doc.title}</p>
{#if metaLine}
<p class="mb-2 text-sm text-ink-3">{metaLine}</p>
{/if}
<a
href="/documents/{doc.id}"
aria-label={openAriaLabel}
class="-my-2 inline-flex min-h-[44px] items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
<path
d="M3 4h4M3 6.5h4M3 9h2"
stroke="currentColor"
stroke-width=".7"
stroke-linecap="round"
/>
</svg>
{m.journey_item_open()}
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 10" fill="none">
<path
d="M4 2l4 3-4 3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
{#if hasNote}
<!-- plaintext — do NOT use {@html} here -->
<div class="mt-3 rounded-r-sm border-l-2 border-brand-mint bg-muted py-1.5 pr-2 pl-3">
<p class="text-base leading-relaxed text-ink-2 italic">{item.note}</p>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,188 @@
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';
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: 'DAY',
receiverCount: 0
},
...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 in the meta line when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
await expect.element(page.getByText(/1923/)).toBeVisible();
});
it('"Brief öffnen" link points to /documents/:id', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = page.getByRole('link', { name: /öffnen/i });
await expect.element(link).toBeInTheDocument();
const el = await link.element();
expect(el.getAttribute('href')).toContain('/documents/d1');
});
it('"Brief öffnen" link meets the 44px touch-target floor', async () => {
// Primary tap action of the phone read path — WCAG 2.5.5 / house rule.
render(JourneyItemCard, { props: { item: baseItem() } });
const link = page.getByRole('link', { name: /öffnen/i });
await expect.element(link).toBeInTheDocument();
const height = link.element().getBoundingClientRect().height;
expect(height).toBeGreaterThanOrEqual(44);
});
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = page.getByRole('link', { name: /1923/i });
await expect.element(link).toBeInTheDocument();
});
it('"Brief öffnen" link has undated aria-label when documentDate is absent', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
})
}
});
const link = page.getByRole('link', { name: m.journey_item_open_aria_undated() });
await expect.element(link).toBeInTheDocument();
});
it('omits date from meta line when documentDate is absent', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
})
}
});
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
});
it('renders sender→receiver in meta line when both are present', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: {
id: 'd1',
title: 'Brief an Helene',
documentDate: '1923-05-15',
datePrecision: 'DAY',
senderName: 'Franz Raddatz',
receiverName: 'Emma Müller',
receiverCount: 1
}
})
}
});
await expect.element(page.getByText(/Franz Raddatz/)).toBeVisible();
await expect.element(page.getByText(/Emma Müller/)).toBeVisible();
});
it('renders note as annotation block when note is present', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
});
it('annotation block uses the brand mint accent border (#798)', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const note = document.querySelector('[class*="border-l-2"]');
expect(note).not.toBeNull();
expect(note!.className).toContain('border-brand-mint');
});
it('card uses the surface token, not bg-white, so dark mode remaps it', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const card = document.querySelector('[class*="border-line"]');
expect(card).not.toBeNull();
expect(card!.className).toContain('bg-surface');
expect(card!.className).not.toContain('bg-white');
});
it('annotation block is tinted with bg-muted to stand off the white card', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const note = document.querySelector('[class*="border-l-2"]');
expect(note!.className).toContain('bg-muted');
});
it('reading text sizes meet the accessibility floor (#800)', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const title = page.getByText('Brief an Helene');
expect((await title.element()).className).toContain('text-base');
const link = await page.getByRole('link', { name: /öffnen/i }).element();
expect(link.className).toContain('text-sm');
expect(link.className).not.toContain('text-xs');
const noteText = document.querySelector('[class*="border-l-2"] p');
expect(noteText!.className).toContain('text-base');
expect(noteText!.className).not.toContain('text-xs');
});
it('omits annotation block when note is blank or whitespace', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
await expect.element(page.getByText(/ {3}/)).not.toBeInTheDocument();
});
it('omits annotation block when note is absent', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
const notes = document.querySelectorAll('[class*="border-mint"]');
expect(notes.length).toBe(0);
});
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
// Note uses Svelte text interpolation ({item.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=');
});
});

View File

@@ -0,0 +1,268 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { formatDocumentMetaLine } from './utils';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
item: JourneyItemView;
index: number;
total: number;
pendingRemove?: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
onNotePatch: (note: string | null) => Promise<void>;
}
let {
item,
index,
total,
pendingRemove = false,
onMoveUp,
onMoveDown,
onRemove,
onNotePatch
}: Props = $props();
const isInterlude = $derived(!item.document);
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
// Spec LE-2 "Briefmeta": date · von X an Y disambiguates near-identical titles.
const metaLine = $derived(item.document ? formatDocumentMetaLine(item.document) : '');
const needsConfirmOnRemove = $derived(!!item.note);
let rootEl: HTMLElement | null = $state(null);
let showNote = $state(!!item.note);
let noteDraft = $state(item.note ?? '');
let noteSaving = $state(false);
let noteError = $state('');
let showRemoveConfirm = $state(false);
async function handleNoteBlur() {
if (noteSaving) return;
const normalizedDraft = noteDraft.trim().length === 0 ? null : noteDraft;
// '' and undefined both mean "no note" — never PATCH a no-op.
if (normalizedDraft === (item.note ?? null)) {
// Opened "Notiz hinzufügen" and blurred without typing → collapse again.
if (!isInterlude && normalizedDraft === null) showNote = false;
return;
}
if (isInterlude && normalizedDraft === null) {
// Interludes must keep a note — restore the draft so the UI doesn't show
// an emptied text that the server still holds.
noteDraft = item.note ?? '';
return;
}
noteSaving = true;
noteError = '';
try {
await onNotePatch(normalizedDraft);
// Clearing an existing note collapses the textarea after the PATCH lands.
if (normalizedDraft === null) showNote = false;
} catch (e) {
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
} finally {
noteSaving = false;
}
}
async function handleNoteRemove() {
const prevDraft = noteDraft;
const prevShowNote = showNote;
noteDraft = '';
showNote = false;
noteError = '';
try {
await onNotePatch(null);
} catch (e) {
noteDraft = prevDraft;
showNote = prevShowNote;
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
}
}
async function handleNoteOpen() {
showNote = true;
// Spec LE-3: focus moves into the revealed textarea.
await tick();
rootEl?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
}
async function handleRemoveClick() {
if (needsConfirmOnRemove) {
showRemoveConfirm = true;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-confirm-cancel]')?.focus();
} else {
onRemove();
}
}
function handleRemoveConfirm() {
showRemoveConfirm = false;
onRemove();
}
async function handleRemoveCancel() {
showRemoveConfirm = false;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
}
</script>
<div
bind:this={rootEl}
data-block-id={item.id}
class={[
'flex min-w-0 flex-col rounded border transition-colors',
pendingRemove ? 'opacity-60' : '',
isInterlude
? 'border-l-4 border-line border-l-interlude-border bg-interlude-bg'
: 'border-line bg-surface'
].join(' ')}
>
<div class="flex min-w-0 items-center gap-1 px-2 py-1">
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
<button
type="button"
data-drag-handle
tabindex="-1"
aria-hidden="true"
class="hidden shrink-0 cursor-grab items-center justify-center self-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
style="min-height: 44px; min-width: 44px;"
>
</button>
<!-- Move up/down (mobile + always visible) -->
<div class="flex shrink-0 flex-col self-start">
<button
type="button"
data-move-up
onclick={onMoveUp}
disabled={index === 0}
aria-label={m.journey_move_up({ title: itemTitle })}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onclick={onMoveDown}
disabled={index === total - 1}
aria-label={m.journey_move_down({ title: itemTitle })}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- Content (title + note inline) -->
<div class="min-w-0 flex-1 py-1 break-words">
{#if isInterlude}
<span class="font-sans text-xs font-bold tracking-widest text-interlude-label uppercase">
{m.journey_interlude_label()}
</span>
{:else}
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
{#if metaLine}
<p class="mt-0.5 font-sans text-xs text-ink-3">{metaLine}</p>
{/if}
{/if}
{#if showNote}
<div class="mt-2">
<textarea
aria-label={m.journey_note_aria_label({ title: itemTitle })}
bind:value={noteDraft}
onblur={handleNoteBlur}
maxlength={2000}
rows={2}
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<div class="mt-1 flex items-center justify-between gap-2">
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
{#if !isInterlude}
<button
type="button"
onclick={handleNoteRemove}
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_note_remove()}
</button>
{/if}
</div>
{#if noteError}
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
{/if}
</div>
{:else if !isInterlude}
<button
type="button"
onclick={handleNoteOpen}
aria-expanded={showNote}
class="mt-0.5 inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_note_add()}
</button>
{/if}
</div>
<!-- Remove button / confirm / pending -->
<div class="shrink-0 self-start">
{#if pendingRemove}
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
{m.journey_item_pending_remove()}
</span>
{:else if showRemoveConfirm}
<div role="group" aria-label={m.journey_remove_confirm()} class="flex items-center gap-2">
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
<button
type="button"
onclick={handleRemoveConfirm}
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_yes()}
</button>
<button
type="button"
data-remove-confirm-cancel
onclick={handleRemoveCancel}
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_cancel()}
</button>
</div>
{:else}
<button
type="button"
data-remove-btn
onclick={handleRemoveClick}
aria-label={m.journey_remove_item_aria({ title: itemTitle })}
class="-m-1 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,354 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyItemRow from './JourneyItemRow.svelte';
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
id: 'item-1',
position: 0,
document: {
id: 'doc-1',
title: 'Brief von Karl',
datePrecision: 'DAY' as const,
receiverCount: 0
},
...overrides
});
const interludeItem = (note = 'Reise nach Wien') => ({
id: 'item-2',
position: 1,
note
});
const defaultProps = (overrides = {}) => ({
index: 0,
total: 3,
onMoveUp: vi.fn(),
onMoveDown: vi.fn(),
onRemove: vi.fn(),
onNotePatch: vi.fn().mockResolvedValue(undefined),
...overrides
});
afterEach(() => cleanup());
describe('JourneyItemRow — interlude label', () => {
it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument();
await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument();
});
it('uses "Zwischentext" in the move button aria-labels', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) });
await expect
.element(
page.getByRole('button', {
name: m.journey_move_up({ title: m.journey_interlude_label() })
})
)
.toBeInTheDocument();
});
});
describe('JourneyItemRow — note textarea', () => {
it('opens note textarea on "Notiz hinzufügen" click', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
await userEvent.click(page.getByText(m.journey_note_add()));
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
.toBeInTheDocument();
});
it('blur without typing does not call onNotePatch and collapses the textarea', async () => {
// '' (untouched draft) and undefined (no note) both mean "no note" — a
// spurious PATCH {note: null} must not fire, and the empty textarea closes.
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).not.toHaveBeenCalled();
await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument();
});
it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
const toggle = page.getByText(m.journey_note_add());
expect(toggle.element().getAttribute('aria-expanded')).toBe('false');
await userEvent.click(toggle);
await vi.waitFor(() => {
const textarea = page
.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })
.element();
expect(document.activeElement).toBe(textarea);
});
});
it('calls onNotePatch on textarea blur with non-empty value', async () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await userEvent.fill(textarea, 'Eine neue Notiz');
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
});
it('limits the note textarea to 2000 characters', async () => {
render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() });
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await expect.element(textarea).toHaveAttribute('maxlength', '2000');
});
});
describe('JourneyItemRow — note error state', () => {
it('shows role=alert error message when onNotePatch rejects', async () => {
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await userEvent.fill(textarea, 'Eine Notiz');
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
});
describe('JourneyItemRow — note remove error state', () => {
it('restores note and shows error when onNotePatch rejects during remove', async () => {
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
render(JourneyItemRow, {
item: docItem({ note: 'keep me' }),
...defaultProps({ onNotePatch })
});
await userEvent.click(page.getByText(m.journey_note_remove()));
// textarea should be visible again (showNote restored)
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// error alert should be shown
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
});
describe('JourneyItemRow — interlude rules', () => {
it('does not show "Notiz entfernen" for interlude items', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
// Note section should be visible (interlude always shows note)
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// But "Notiz entfernen" must be absent
await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
});
it('blocks saving empty text on interlude note blur', async () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, {
item: interludeItem('original text'),
...defaultProps({ onNotePatch })
});
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).not.toHaveBeenCalled();
});
it('restores the original note text after a blocked empty-clear blur', async () => {
render(JourneyItemRow, {
item: interludeItem('original text'),
...defaultProps()
});
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await expect.element(textarea).toHaveValue('original text');
});
});
describe('JourneyItemRow — remove confirm', () => {
it('shows inline confirm when removing a document item that has a note', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
// Click remove (x button)
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.journey_remove_confirm_yes() }))
.toBeInTheDocument();
});
it('clicking Bestätigen invokes onRemove (destructive path)', async () => {
const onRemove = vi.fn();
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps({ onRemove })
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('confirm cancel restores remove button without calling onRemove', async () => {
const onRemove = vi.fn();
render(JourneyItemRow, {
item: docItem({ note: 'Notiz' }),
...defaultProps({ onRemove })
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
expect(onRemove).not.toHaveBeenCalled();
// The remove button should be back
await expect
.element(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
)
.toBeInTheDocument();
});
it('confirm cancel returns keyboard focus to the row remove button', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
await vi.waitFor(() => {
const removeBtn = page
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
.element();
expect(document.activeElement).toBe(removeBtn);
});
});
});
describe('JourneyItemRow — remove confirm a11y', () => {
it('confirm area is wrapped in role=group with an accessible label', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
const group = document.querySelector('[role="group"]');
expect(group).toBeTruthy();
expect(group!.getAttribute('aria-label')).toBeTruthy();
});
it('keyboard focus moves to Cancel button when confirm appears', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await vi.waitFor(() => {
const cancelBtn = page
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
.element();
expect(document.activeElement).toBe(cancelBtn);
});
});
it('pressing Escape while confirm is open hides confirm and refocuses remove button', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await vi.waitFor(() => {
const cancelBtn = page
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
.element();
expect(document.activeElement).toBe(cancelBtn);
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(() => {
const removeBtn = page
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
.element();
expect(document.activeElement).toBe(removeBtn);
});
await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument();
});
});
describe('JourneyItemRow — pending remove state', () => {
it('renders dimmed with the pending text and without a remove button', async () => {
render(JourneyItemRow, {
item: docItem(),
...defaultProps({ pendingRemove: true })
});
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
await expect
.element(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
)
.not.toBeInTheDocument();
const root = document.querySelector('[data-block-id="item-1"]')!;
expect(root.className).toContain('opacity-60');
});
});
describe('JourneyItemRow — drag handle', () => {
it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
const handle = document.querySelector('[data-drag-handle]')!;
expect(handle.getAttribute('tabindex')).toBe('-1');
expect(handle.getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -0,0 +1,53 @@
<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;
}
let { geschichte: g }: 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-6 border-b border-dashed border-line-2 pb-4 font-serif text-lg 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">
{#each validItems as item (item.id)}
<li>
{#if item.document != null}
<JourneyItemCard item={item} />
{:else}
<JourneyInterlude note={item.note!} />
{/if}
</li>
{/each}
</ol>
{/if}

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } 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: 'DAY',
documentDate: '1923-05-15',
receiverCount: 0
},
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.' }) }
});
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
});
it('intro paragraph uses readable body size (text-lg, #800)', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
});
const intro = document.querySelector('p');
expect(intro!.className).toContain('text-lg');
expect(intro!.className).not.toContain('text-sm');
});
it('omits intro paragraph when body is null', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: undefined }) }
});
// 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: ' ' }) }
});
// 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: [] }) }
});
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: [] })
}
});
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)] })
}
});
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)] })
}
});
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
});
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)
]
})
}
});
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('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">'
})
}
});
expect(window.__xss_journey).toBeUndefined();
expect(document.body.textContent).toContain('<img src=x onerror=');
});
});

View File

@@ -1,10 +1,11 @@
# 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
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `StoryDocumentPanel.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
Utilities: `utils.ts`.
## What this domain does NOT own
@@ -14,14 +15,44 @@ Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
## Key components
| Component | Used in | Notes |
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
| `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 |
| Component | Used in | Notes |
| --------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
| `StoryDocumentPanel.svelte` | `GeschichteSidebar` (STORY edit only) | Sidebar document list for stories: picker add (POST), optimistic remove (DELETE), deleted-doc placeholders |
| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
| `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) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
| `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` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
## utils.ts
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to the localized `person_unknown` key (for list/summary shapes; email is not exposed).
`formatAuthorDisplayName(author)` — returns `displayName`, localizing the server's `[Unbekannt]` fallback (for detail `AuthorView` shape).
`formatDocumentMetaLine(doc)``"12.07.1938 · von Franz an Emma"`; shared by `JourneyItemCard`, `JourneyItemRow`, and the story doc-reference cards.
`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
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

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { getErrorMessage } from '$lib/shared/errors';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
geschichteId: string;
items?: JourneyItemView[];
}
let { geschichteId, items: initialItems = [] }: Props = $props();
const uid = $props.id();
const pickerInputId = `story-doc-picker-${uid}`;
// Initial-state snapshot — the panel owns the list after mount and updates
// it from API responses; the parent re-mounts to reset (same contract as
// GeschichteEditor/JourneyEditor).
// svelte-ignore state_referenced_locally
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
let errorMessage = $state('');
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
let sectionEl: HTMLElement | null = $state(null);
$effect(() => () => {
if (announceTimer) clearTimeout(announceTimer);
});
const alreadyAddedIds = $derived(
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
function announce(message: string) {
liveAnnounce = message;
if (announceTimer) clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveAnnounce = '';
announceTimer = null;
}, 500);
}
function itemTitle(item: JourneyItemView): string {
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
}
/** Maps a failed mutation to a user-facing message — story wording for the
* two journey-flavored 409s, whose generic messages say "Lesereise". */
async function failureMessage(res: Response): Promise<string> {
const code = (await res.json().catch(() => ({})))?.code;
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}
/** Pessimistic append — the list updates only with the server's response. */
async function handleAdd(doc: DocumentOption) {
errorMessage = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: doc.id })
});
if (!res.ok) {
errorMessage = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
} catch (e) {
console.error('Story document add failed', e);
errorMessage = m.journey_mutation_error_reload();
}
}
/** The removed row's button leaves the DOM — without this, focus drops to
* <body> and a keyboard user is teleported to page top. */
async function moveFocusAfterRemove(removedIdx: number) {
await tick();
if (items.length === 0) {
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
return;
}
const target = items[Math.max(removedIdx - 1, 0)];
sectionEl
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(target.id)}"] [data-remove-btn]`)
?.focus();
}
/** Optimistic removal with snapshot-and-rollback. */
async function handleRemove(item: JourneyItemView) {
const idx = items.findIndex((i) => i.id === item.id);
const prev = [...items];
errorMessage = '';
items = items.filter((i) => i.id !== item.id);
await moveFocusAfterRemove(idx);
try {
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
method: 'DELETE'
});
if (!res.ok) {
items = prev;
await tick();
sectionEl
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
?.focus();
errorMessage = await failureMessage(res);
return;
}
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
} catch (e) {
console.error('Story document remove failed', e);
items = prev;
await tick();
sectionEl
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
?.focus();
errorMessage = m.journey_mutation_error_reload();
}
}
</script>
<!-- Screen-reader live region for add/remove confirmations -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_documents_heading()}
</summary>
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
<h2
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_documents_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
{#if errorMessage}
<p
role="alert"
class="mb-3 flex items-start gap-2 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
>
<svg
class="mt-0.5 h-4 w-4 flex-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
{errorMessage}
</p>
{/if}
{#if items.length === 0}
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
{:else}
<ul class="m-0 mb-3 flex list-none flex-col p-0">
{#each items as item (item.id)}
<li
data-item-id={item.id}
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
>
{#if item.document}
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{item.document.title}
</span>
{:else}
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
{m.geschichte_documents_deleted_placeholder()}
</span>
{/if}
<button
type="button"
data-remove-btn
onclick={() => handleRemove(item)}
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
{m.geschichte_documents_picker_label()}
</label>
<DocumentPickerDropdown
inputId={pickerInputId}
alreadyAddedIds={alreadyAddedIds}
placeholder={m.geschichte_documents_picker_placeholder()}
onSelect={handleAdd}
/>
</section>
</details>

View File

@@ -0,0 +1,446 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const,
receiverCount: 0
});
const makeItem = (
id: string,
position: number,
document?: ReturnType<typeof docSummary>,
note?: string
) => ({ id, position, document, note });
/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
type MutationResponse = { ok: boolean; status?: number; body?: object };
/**
* Routes the picker's GET search to `searchItems` and every mutation
* (POST/DELETE) to `mutation` — the panel talks to both endpoints.
*/
function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const method = (init?.method ?? 'GET').toUpperCase();
if (method === 'GET') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ items: searchItems })
});
}
return Promise.resolve({
ok: mutation.ok,
status: mutation.status ?? (mutation.ok ? 200 : 500),
json: () => Promise.resolve(mutation.body ?? {})
});
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
geschichteId: 'g1',
items: [],
...overrides
});
async function addViaPicker(title: RegExp) {
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await expect.element(page.getByText(title)).toBeInTheDocument();
await userEvent.click(page.getByText(title));
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('StoryDocumentPanel — rendering', () => {
it('renders linked documents sorted by position', async () => {
render(
StoryDocumentPanel,
defaultProps({
items: [
makeItem('i3', 30, docSummary('d3', 'Dritter Brief')),
makeItem('i1', 10, docSummary('d1', 'Erster Brief'))
]
})
);
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
expect(rows[0]).toContain('Erster Brief');
expect(rows[1]).toContain('Dritter Brief');
});
it('shows the empty state when no items are linked', async () => {
render(StoryDocumentPanel, defaultProps());
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
});
it('renders a deleted-document item as placeholder row that is still removable', async () => {
render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] }));
await expect
.element(page.getByText(m.geschichte_documents_deleted_placeholder()))
.toBeInTheDocument();
await expect
.element(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({
title: m.geschichte_documents_deleted_placeholder()
})
})
)
.toBeInTheDocument();
});
it('wires a visible label to the picker input', async () => {
render(StoryDocumentPanel, defaultProps());
const input = page.getByRole('combobox').element() as HTMLInputElement;
const label = document.querySelector(`label[for="${input.id}"]`);
expect(label?.textContent).toContain(m.geschichte_documents_picker_label());
});
});
describe('StoryDocumentPanel — add', () => {
it('POSTs to the items endpoint and appends the created item', async () => {
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: true,
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post?.[0]).toBe('/api/geschichten/g1/items');
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' });
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
});
it('marks an already-linked document as not selectable in the dropdown', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await expect.element(page.getByRole('option')).toBeInTheDocument();
const option = document.querySelector('[role="listbox"] [role="option"]');
expect(option?.getAttribute('aria-disabled')).toBe('true');
});
it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: false,
status: 409,
body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.geschichte_documents_duplicate());
});
it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: false,
status: 409,
body: { code: 'JOURNEY_AT_CAPACITY' }
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.geschichte_documents_capacity());
});
it('announces a successful add via the polite live region', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: true,
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion?.textContent).toBe(
m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' })
);
});
it('routes a 403 response through getErrorMessage on POST', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: false,
status: 403,
body: { code: 'FORBIDDEN' }
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const alertText = page.getByRole('alert').element().textContent ?? '';
expect(alertText).not.toBe('');
expect(alertText).not.toContain('FORBIDDEN');
});
it('shows the generic reload message when POST throws a network error', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if ((init?.method ?? 'GET').toUpperCase() === 'GET') {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] })
});
}
return Promise.reject(new Error('Network error'));
})
);
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.journey_mutation_error_reload());
});
it('attaches X-XSRF-TOKEN header from cookie on POST', async () => {
document.cookie = 'XSRF-TOKEN=test-csrf-token';
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: true,
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
const headers = post?.[1]?.headers as Headers;
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
});
});
describe('StoryDocumentPanel — remove', () => {
it('DELETEs the item endpoint and removes the row', async () => {
const fetchMock = stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
expect(del?.[0]).toBe('/api/geschichten/g1/items/i1');
expect(document.querySelectorAll('li').length).toBe(0);
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
});
it('restores the row and shows an error when the DELETE fails', async () => {
stubFetch([], { ok: false, status: 500, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
});
it('moves focus to the previous row remove button instead of dropping to body', async () => {
stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({
items: [
makeItem('i1', 10, docSummary('d1', 'Erster Brief')),
makeItem('i2', 20, docSummary('d2', 'Zweiter Brief'))
]
})
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' })
})
);
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.getAttribute('aria-label')).toBe(
m.geschichte_documents_remove_label({ title: 'Erster Brief' })
);
});
it('moves focus to the picker input when the last item is removed', async () => {
stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const input = page.getByRole('combobox').element();
expect(document.activeElement).toBe(input);
});
it('announces a successful remove via the polite live region', async () => {
stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion?.textContent).toBe(
m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' })
);
});
it('returns focus to the item remove button when DELETE fails with !res.ok', async () => {
stubFetch([], { ok: false, status: 500, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
expect(document.activeElement?.getAttribute('aria-label')).toBe(
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
);
});
it('returns focus to the item remove button when DELETE throws a network error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Network error')))
);
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
expect(document.activeElement?.getAttribute('aria-label')).toBe(
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
);
});
it('shows the generic reload message when DELETE throws a network error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Network error')))
);
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.journey_mutation_error_reload());
});
it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => {
document.cookie = 'XSRF-TOKEN=test-csrf-token';
const fetchMock = stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
const headers = del?.[1]?.headers as Headers;
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
});
});

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { formatDocumentMetaLine } from './utils';
import type { components } from '$lib/generated/api';
type GeschichteView = components['schemas']['GeschichteView'];
interface Props {
geschichte: GeschichteView;
}
let { geschichte: g }: Props = $props();
const sanitized = $derived(safeHtml(g.body));
const documentItems = $derived(g.items.filter((i) => i.document));
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 gap-2 rounded-full border border-line bg-surface px-3 py-1.5 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<span
aria-hidden="true"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-sans text-[8px] font-bold text-white"
style="background-color: {personAvatarColor(p.id)}"
>
{getInitials(personName(p))}
</span>
{personName(p)}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Dokumente (JourneyItems) -->
{#if documentItems.length > 0}
<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 documentItems as item (item.id)}
<li>
<a
href="/documents/{item.document!.id}"
class="flex items-start gap-3 rounded-sm border border-line bg-surface p-3 transition-shadow hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<span
aria-hidden="true"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted"
>
<svg class="h-4 w-4 text-ink-3" viewBox="0 0 10 12" fill="none">
<rect
x="1"
y="1"
width="8"
height="10"
rx="1"
stroke="currentColor"
stroke-width="1"
/>
<path
d="M3 4h4M3 6.5h4M3 9h2"
stroke="currentColor"
stroke-width=".8"
stroke-linecap="round"
/>
</svg>
</span>
<span class="min-w-0">
<span class="block font-sans text-sm leading-snug font-semibold text-ink">
{item.document!.title}
</span>
{#if formatDocumentMetaLine(item.document!)}
<span class="block font-sans text-xs text-ink-3">
{formatDocumentMetaLine(item.document!)}
</span>
{/if}
</span>
</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}

View File

@@ -0,0 +1,134 @@
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: 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
});
describe('StoryReader', () => {
it('renders body HTML content', async () => {
render(StoryReader, { props: { geschichte: baseGeschichte() } });
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
});
it('omits persons section when persons array is empty', async () => {
render(StoryReader, { props: { geschichte: baseGeschichte({ persons: [] }) } });
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
});
it('renders persons section with firstName + lastName joined', async () => {
render(StoryReader, {
props: {
geschichte: baseGeschichte({
persons: [
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
]
})
}
});
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, { props: { geschichte: baseGeschichte({ items: [] }) } });
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
});
it('renders document reference cards with title and link for items with documents', async () => {
render(StoryReader, {
props: {
geschichte: baseGeschichte({
items: [
{
id: 'i1',
position: 0,
document: {
id: 'd1',
title: 'Brief vom 12. Juli 1938',
documentDate: '1938-07-12',
senderName: 'Franz Raddatz',
receiverName: 'Emma Müller',
datePrecision: 'DAY',
receiverCount: 1
} as unknown as NonNullable<components['schemas']['JourneyItemView']['document']>,
note: 'Wichtiger Brief'
}
]
})
}
});
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
await expect.element(page.getByText('Brief vom 12. Juli 1938')).toBeVisible();
await expect.element(page.getByText(/von Franz Raddatz an Emma Müller/)).toBeVisible();
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
const link = document.querySelector<HTMLAnchorElement>('a[href="/documents/d1"]');
expect(link).not.toBeNull();
});
it('person chip link meets 44px touch-target minimum height', async () => {
render(StoryReader, {
props: {
geschichte: baseGeschichte({
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
})
}
});
const link = document.querySelector<HTMLAnchorElement>('a[href^="/persons/"]');
const rect = link?.getBoundingClientRect();
expect(rect?.height).toBeGreaterThanOrEqual(44);
});
it('person chip shows avatar initials', async () => {
render(StoryReader, {
props: {
geschichte: baseGeschichte({
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
})
}
});
const chip = document.querySelector<HTMLAnchorElement>('a[href="/persons/p1"]');
expect(chip?.textContent).toContain('HS');
});
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, {
props: {
geschichte: baseGeschichte({
body: '<img src=x onerror="(window as any).__xss_story=1">'
})
}
});
expect((window as { __xss_story?: number }).__xss_story).toBeUndefined();
});
});

View File

@@ -0,0 +1,65 @@
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' })).toBe('Anna Schmidt');
});
it('returns firstName alone when lastName is absent', () => {
expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna');
});
it('returns lastName alone when firstName is absent', () => {
expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt');
});
it('falls back to [Unbekannt] when both names are absent', () => {
expect(formatAuthorName({})).toBe('[Unbekannt]');
});
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);
});
});

View File

@@ -0,0 +1,39 @@
import { formatDate } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
import { joinNameOrUnknown, unknownPersonName } from '$lib/person/personFormat';
type AuthorSummary = { firstName?: string; lastName?: string };
type DocumentMeta = { documentDate?: string; senderName?: string; receiverName?: string };
type AuthorView = { displayName: string };
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
if (!author) return '';
// Email is no longer exposed — names or the localized fallback only.
return joinNameOrUnknown(author.firstName, author.lastName);
}
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
if (!author) return '';
// The server-side fallback is the literal '[Unbekannt]' — localize it here.
return author.displayName === '[Unbekannt]' ? unknownPersonName() : 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);
}
/** "12.07.1938 · von Franz an Emma" — shared by JourneyItemCard and the story doc-reference cards. */
export function formatDocumentMetaLine(doc: DocumentMeta): string {
const parts: string[] = [];
if (doc.documentDate) parts.push(formatDate(doc.documentDate, 'short'));
if (doc.senderName && doc.receiverName) {
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
} else if (doc.senderName) {
parts.push(doc.senderName);
}
return parts.join(' · ');
}

View File

@@ -2,10 +2,11 @@
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import type { PersonOption } from './personOption';
type Person = components['schemas']['Person'];
interface Props {
selectedPersons?: Person[];
selectedPersons?: PersonOption[];
}
let { selectedPersons = $bindable([]) }: Props = $props();

View File

@@ -1,4 +1,5 @@
import { formatDate } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
type Person = { firstName?: string | null; lastName: string; displayName: string };
type DocForMeta = {
@@ -17,6 +18,19 @@ function djb2(str: string): number {
return Math.abs(hash);
}
/** Localized fallback when a person has no name parts. */
export function unknownPersonName(): string {
return m.person_unknown();
}
/**
* Single source for the join-names-or-fallback rule. Mirrors the server-side
* fallback in GeschichteService.toView (which emits the literal '[Unbekannt]').
*/
export function joinNameOrUnknown(firstName?: string | null, lastName?: string | null): string {
return [firstName, lastName].filter(Boolean).join(' ').trim() || unknownPersonName();
}
export function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '';

View File

@@ -0,0 +1,27 @@
import type { components } from '$lib/generated/api';
import { joinNameOrUnknown } from './personFormat';
type Person = components['schemas']['Person'];
/**
* Narrow chip/dedup contract for person pickers: exactly what PersonMultiSelect
* renders. Full `Person` objects (search results) are structurally assignable;
* view projections without a displayName go through {@link toPersonOption}.
*/
export type PersonOption = Pick<Person, 'id' | 'displayName'>;
/**
* Maps a name-carrying projection (e.g. GeschichteView.PersonView, which has no
* server-computed displayName) into the chip contract. Mirrors the server-side
* fallback in GeschichteService.toView.
*/
export function toPersonOption(p: {
id: string;
firstName?: string | null;
lastName?: string | null;
}): PersonOption {
return {
id: p.id,
displayName: joinNameOrUnknown(p.firstName, p.lastName)
};
}

View File

@@ -18,9 +18,8 @@ export function radioGroupNav(
const delta = event.key === 'ArrowRight' ? 1 : -1;
const next = (current + delta + radios.length) % radios.length;
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
radios.forEach((r, i) => r.setAttribute('aria-checked', i === next ? 'true' : 'false'));
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}

View File

@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
drafts: Geschichte[];
drafts: GeschichteSummary[];
}
const { drafts }: Props = $props();

View File

@@ -5,24 +5,25 @@ import { page } from 'vitest/browser';
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(() => {
cleanup();
});
const draft1: Geschichte = {
const draft1: GeschichteSummary = {
id: 'g1',
title: 'Mein erster Entwurf',
status: 'DRAFT',
createdAt: '2025-01-01T00:00:00Z',
type: 'STORY',
updatedAt: '2025-01-02T00:00:00Z'
};
const draft2: Geschichte = {
const draft2: GeschichteSummary = {
id: 'g2',
title: 'Zweiter Entwurf',
status: 'DRAFT',
type: 'STORY',
createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z'
};

View File

@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
stories: Geschichte[];
stories: GeschichteSummary[];
}
const { stories }: Props = $props();

View File

@@ -5,27 +5,28 @@ import { page } from 'vitest/browser';
import ReaderRecentStories from './ReaderRecentStories.svelte';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(() => {
cleanup();
});
const story1: Geschichte = {
const story1: GeschichteSummary = {
id: 'g1',
title: 'Die Familie Müller',
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
status: 'PUBLISHED',
createdAt: '2025-01-01T00:00:00Z',
type: 'STORY',
updatedAt: '2025-01-01T00:00:00Z',
publishedAt: '2025-01-01T00:00:00Z'
};
const longBodyStory: Geschichte = {
const longBodyStory: GeschichteSummary = {
id: 'g2',
title: 'Sehr lange Geschichte',
body: '<p>' + 'A'.repeat(200) + '</p>',
status: 'PUBLISHED',
type: 'STORY',
createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z',
publishedAt: '2025-02-01T00:00:00Z'

View File

@@ -46,6 +46,14 @@ export type ErrorCode =
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'JOURNEY_ITEM_NOT_FOUND'
| 'JOURNEY_ITEM_POSITION_CONFLICT'
| 'JOURNEY_AT_CAPACITY'
| 'JOURNEY_NOTE_TOO_LONG'
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
| 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
@@ -164,6 +172,22 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'JOURNEY_ITEM_NOT_FOUND':
return m.error_journey_item_not_found();
case 'JOURNEY_ITEM_POSITION_CONFLICT':
return m.error_journey_item_position_conflict();
case 'JOURNEY_AT_CAPACITY':
return m.error_journey_at_capacity();
case 'JOURNEY_NOTE_TOO_LONG':
return m.error_journey_note_too_long();
case 'JOURNEY_DOCUMENT_ALREADY_ADDED':
return m.error_journey_document_already_added();
case 'GESCHICHTE_TYPE_IMMUTABLE':
return m.error_geschichte_type_immutable();
case 'GESCHICHTE_TITLE_TOO_LONG':
return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long();
case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials();
case 'SESSION_EXPIRED':

View File

@@ -1,7 +1,35 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, expectTypeOf } from 'vitest';
import { createBlockDragDrop } from './useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types';
// ---------------------------------------------------------------------------
// Type-regression guard: createBlockDragDrop must accept any T extends {id: string}
// so JourneyEditor can reuse it without importing TranscriptionBlockData.
// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit
// until the function is made generic.
// ---------------------------------------------------------------------------
describe('createBlockDragDrop — generic type guard', () => {
it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => {
type SimpleItem = { id: string; position: number };
const items: SimpleItem[] = [
{ id: 'item-1', position: 0 },
{ id: 'item-2', position: 1 }
];
const onReorder = vi.fn();
const dd = createBlockDragDrop<SimpleItem>({ getSortedBlocks: () => items, onReorder });
// Verify the hook is functional with the new type — state reads must work
expect(dd.draggedBlockId).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => {
// If the generic constraint is wrong this line fails tsc --noEmit
expectTypeOf(createBlockDragDrop<TranscriptionBlockData>).toBeFunction();
// Runtime assertion so browser-mode doesn't report "no assertions"
expect(typeof createBlockDragDrop).toBe('function');
});
});
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return {
id,

View File

@@ -1,11 +1,12 @@
import type { TranscriptionBlockData } from '$lib/shared/types';
type Options = {
getSortedBlocks: () => TranscriptionBlockData[];
type Options<T extends { id: string }> = {
getSortedBlocks: () => T[];
onReorder: (blockIds: string[]) => void;
};
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
export function createBlockDragDrop<T extends { id: string }>({
getSortedBlocks,
onReorder
}: Options<T>) {
let draggedBlockId = $state<string | null>(null);
let dropTargetIdx = $state<number | null>(null);
let dragOffsetY = $state(0);

View File

@@ -78,6 +78,56 @@ describe('createTypeahead', () => {
errorSpy.mockRestore();
});
it('fetch error sets the error flag so callers can render a failure state', async () => {
const fetchUrl = vi.fn().mockRejectedValue(new Error('500'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
expect(ta.error).toBe(false);
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
errorSpy.mockRestore();
});
it('error flag clears on the next successful fetch', async () => {
const fetchUrl = vi
.fn()
.mockRejectedValueOnce(new Error('500'))
.mockResolvedValueOnce([{ id: '1' }]);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
ta.setQuery('foob');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(false);
expect(ta.results).toEqual([{ id: '1' }]);
errorSpy.mockRestore();
});
it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
// During the debounce window no fetch has run yet — callers must be able to
// distinguish "still searching" from "searched, zero hits".
expect(ta.loading).toBe(true);
await vi.advanceTimersByTimeAsync(300);
expect(ta.loading).toBe(false);
});
it('close() cancels the pending debounce — no stale fetch fires, loading resets', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1' }]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
expect(ta.loading).toBe(true);
ta.close();
expect(ta.loading).toBe(false);
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).not.toHaveBeenCalled();
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);

View File

@@ -11,6 +11,7 @@ export function createTypeahead<T>(options: Options<T>) {
let results: T[] = $state([]);
let isOpen = $state(false);
let loading = $state(false);
let error = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
@@ -18,14 +19,18 @@ export function createTypeahead<T>(options: Options<T>) {
function setQuery(q: string) {
query = q;
isOpen = true;
// Set loading before the debounce fires so callers can distinguish
// "still searching" from "searched, zero hits" during the debounce window.
loading = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
error = false;
try {
results = await fetchUrl(q);
} catch (e) {
console.error('typeahead fetch error', e);
results = [];
error = true;
} finally {
loading = false;
}
@@ -35,6 +40,10 @@ export function createTypeahead<T>(options: Options<T>) {
function close() {
isOpen = false;
activeIndex = -1;
// Cancel the pending debounce — an abandoned query must not fire a stale
// fetch after the dropdown closed, nor leave loading stuck on true.
clearTimeout(debounceTimer);
loading = false;
}
function setActiveIndex(idx: number) {
@@ -65,6 +74,9 @@ export function createTypeahead<T>(options: Options<T>) {
get loading() {
return loading;
},
get error() {
return error;
},
get activeIndex() {
return activeIndex;
},

View File

@@ -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', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');

View File

@@ -1,13 +1,18 @@
/**
* **Not a sanitizer.** This module extracts visible text from a (presumed
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
* because the Geschichte body is sanitised against the OWASP allow-list
* on the server before persistence, and via DOMPurify on render.
* **Not a sanitizer.** This module extracts visible text from an HTML (or
* plain-text) string for excerpt rendering. The safety invariant is: the
* OUTPUT must only ever be rendered via Svelte text interpolation — never
* `{@html}`. The DOMParser document is inert (scripts don't execute), but
* the returned string is whatever text the input carried.
*
* Note on inputs: STORY bodies are additionally sanitised against the OWASP
* allow-list on the server; JOURNEY intros are stored VERBATIM (unsanitised
* by design — see GeschichteService.bodyForType) and arrive here untrusted.
*
* Do not use these helpers to defend against XSS — `safeHtml()` in
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
* untrusted input that has not been sanitised does not protect against
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
* untrusted input does not protect against `javascript:` URLs,
* event-handler attributes, or `<svg/onload>` payloads.
*/
/**

View File

@@ -11,7 +11,7 @@ type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type DocumentListItem = components['schemas']['DocumentListItem'];
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
@@ -57,9 +57,9 @@ export async function load({ fetch, parent }) {
const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
const recentDocs = searchData?.items ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
const recentStories = settled<GeschichteSummary[]>(recentStoriesRes) ?? [];
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
const drafts = settled<GeschichteSummary[]>(draftsRes) ?? [];
return {
isReader: true as const,
@@ -179,9 +179,9 @@ export async function load({ fetch, parent }) {
readerStats: null,
topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as DocumentListItem[],
recentStories: [] as Geschichte[],
recentStories: [] as GeschichteSummary[],
tagTree: [] as TagTreeNodeDTO[],
drafts: [] as Geschichte[],
drafts: [] as GeschichteSummary[],
error: 'Daten konnten nicht geladen werden.' as string | null
};
}

View File

@@ -47,6 +47,10 @@ describe('SearchFilterBar AND/OR tag operator toggle', () => {
async function openAdvanced() {
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
await filterBtn.click();
// Wait for slide transition to finish before interacting with contents —
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
// (same guard as the undated-only describe below; this block flaked in CI run 2208).
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
}
it('hides AND/OR toggle when fewer than 2 tags are selected', async () => {
@@ -132,6 +136,9 @@ describe('SearchFilterBar undated-only toggle (#668)', () => {
async function openAdvanced() {
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
await filterBtn.click();
// Wait for slide transition to finish before interacting with contents —
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
}
it('renders the "Nur undatierte" toggle in the advanced row', async () => {

View File

@@ -6,21 +6,27 @@ import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person'];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch);
const personIds = url.searchParams.getAll('personId');
const documentId = url.searchParams.get('documentId') ?? undefined;
const rawDocumentId = url.searchParams.get('documentId');
const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null;
const [listResult, ...personResults] = await Promise.all([
const [listResult, docResult, ...personResults] = await Promise.all([
api.GET('/api/geschichten', {
params: {
query: {
status: 'PUBLISHED',
personId: personIds.length ? personIds : undefined,
documentId
documentId: documentId ?? undefined
}
}
}),
documentId
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
: Promise.resolve(null),
...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
]);
@@ -32,9 +38,22 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
.filter((r) => r && r.response.ok && r.data)
.map((r) => r!.data!) as Person[];
let documentFilter: { id: string; title: string | null } | null = null;
if (documentId) {
if (docResult && docResult.response.ok && docResult.data) {
const doc = docResult.data;
documentFilter = {
id: documentId,
title: doc.title ?? doc.originalFilename ?? null
};
} else {
documentFilter = { id: documentId, title: null };
}
}
return {
geschichten: listResult.data ?? [],
personFilters,
documentFilter: documentId ?? null
documentFilter
};
};

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
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 GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte';
import DocumentFilterChip from './DocumentFilterChip.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -11,18 +11,32 @@ let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false);
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
const hasFilters = $derived(data.personFilters.length > 0 || data.documentFilter !== null);
const emptyMessage = $derived.by(() => {
if (data.personFilters.length > 0) {
return m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
});
}
if (data.documentFilter) {
return m.geschichten_empty_for_document();
}
return m.geschichten_empty_no_filter();
});
function rebuildUrl(personIds: string[]) {
const url = new URL(window.location.href);
url.searchParams.delete('personId');
url.searchParams.delete('documentId');
for (const id of personIds) url.searchParams.append('personId', id);
return url.pathname + url.search;
}
function clearAll() {
goto(rebuildUrl([]), { replaceState: true });
const url = new URL(window.location.href);
url.searchParams.delete('personId');
url.searchParams.delete('documentId');
goto(url.pathname + url.search, { replaceState: true });
}
function addPerson(personId: string) {
@@ -38,22 +52,16 @@ function removePerson(personId: string) {
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');
function removeDocument() {
const url = new URL(window.location.href);
url.searchParams.delete('documentId');
goto(url.pathname + url.search);
}
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
<div class="mx-auto max-w-7xl px-4 py-8">
<header class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-2xl text-ink">{m.geschichten_index_title()}</h1>
{#if data.canBlogWrite}
<a
href="/geschichten/new"
@@ -64,86 +72,81 @@ function publishedAt(g: { publishedAt?: string }): string | null {
{/if}
</header>
<!-- Filter pills -->
<div class="mb-6 flex flex-wrap items-center gap-2">
<button
type="button"
aria-pressed={!hasFilters}
onclick={clearAll}
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
>
{m.geschichten_filter_all_pill()}
</button>
{#each data.personFilters as p (p.id)}
<!-- Editorial list card: filter pills + rows share one surface -->
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<!-- Filter pills -->
<div class="flex flex-wrap items-center gap-2 border-b border-line-2 px-3 py-2.5">
<button
type="button"
aria-pressed="true"
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
onclick={() => removePerson(p.id!)}
class="inline-flex h-11 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
aria-pressed={!hasFilters}
onclick={clearAll}
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
>
{p.displayName}
<span aria-hidden="true">×</span>
{m.geschichten_filter_all_pill()}
</button>
{/each}
<button
type="button"
aria-expanded={showPersonPicker}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
</div>
{#if showPersonPicker}
<div class="mb-4">
<PersonTypeahead
name="filter-person"
label={m.geschichten_filter_choose_person()}
compact
autofocus
onchange={addPerson}
/>
{#if selectedPersonIds.length > 1}
<p class="mt-1 font-sans text-xs text-ink-3">
{m.geschichten_filter_and_hint()}
</p>
{/if}
</div>
{/if}
<!-- Card list -->
{#if data.geschichten.length === 0}
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
{#if data.personFilters.length > 0}
{m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
})}
{:else}
{m.geschichten_empty_no_filter()}
{/if}
</div>
{:else}
<ul class="flex flex-col gap-4">
{#each data.geschichten as g (g.id)}
<li
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
{#each data.personFilters as p (p.id)}
<button
type="button"
aria-pressed="true"
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
onclick={() => removePerson(p.id!)}
class="inline-flex h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 font-sans text-xs font-semibold tracking-wider text-primary-fg uppercase"
>
<a href="/geschichten/{g.id}" class="block">
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
<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>
{p.displayName}
<span aria-hidden="true">×</span>
</button>
{/each}
</ul>
{/if}
{#if data.documentFilter}
<DocumentFilterChip
id={data.documentFilter.id}
title={data.documentFilter.title}
onremove={removeDocument}
/>
{/if}
<button
type="button"
aria-expanded={showPersonPicker}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-11 items-center rounded-full border border-dashed border-line px-3 font-sans text-xs font-semibold text-ink-3 hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
</div>
{#if showPersonPicker}
<div class="border-b border-line-2 px-3 py-3">
<PersonTypeahead
name="filter-person"
label={m.geschichten_filter_choose_person()}
compact
autofocus
onchange={addPerson}
/>
{#if selectedPersonIds.length > 1}
<p class="mt-1 font-sans text-xs text-ink-3">
{m.geschichten_filter_and_hint()}
</p>
{/if}
</div>
{/if}
<!-- Rows -->
{#if data.geschichten.length === 0}
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
{emptyMessage}
</div>
{:else}
<ul>
{#each data.geschichten as g (g.id)}
<li class="border-b border-line-2 last:border-b-0">
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<GeschichteListRow geschichte={g} />
</li>
{/each}
</ul>
{/if}
</div>
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
id,
title,
onremove
}: {
id: string;
title: string | null;
onremove: () => void;
} = $props();
const chipLabel = $derived(title ?? id.slice(0, 8));
</script>
<div
class="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 text-primary-fg"
aria-live="polite"
>
<span class="font-sans text-xs tracking-wider whitespace-nowrap uppercase">
{m.geschichten_filter_document_chip()}
</span>
<span class="line-clamp-2 font-serif italic sm:max-w-[16rem] sm:truncate" title={chipLabel}>
{chipLabel}
</span>
<button
type="button"
onclick={onremove}
aria-label={m.geschichten_filter_remove_document_chip({ title: chipLabel })}
class="ml-0.5 flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<span aria-hidden="true">×</span>
</button>
</div>

View File

@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
import DocumentFilterChip from './DocumentFilterChip.svelte';
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
describe('DocumentFilterChip', () => {
it('renders the resolved document title inside the chip', async () => {
render(DocumentFilterChip, {
props: {
id: VALID_UUID,
title: 'Brief an Oma',
onremove: vi.fn()
}
});
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
});
it('renders the prefix label', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
});
it('falls back to short UUID when title is null', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: null, onremove: vi.fn() }
});
await expect.element(page.getByText(/11111111/)).toBeVisible();
});
it('fires onremove when the remove button is clicked', async () => {
const onremove = vi.fn();
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove }
});
const btn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
btn.click();
await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce());
});
it('remove button aria-label references the resolved title', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ });
await expect.element(btn).toBeVisible();
});
it('title= attribute equals the validated id, not a raw query string', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
const chip = document.querySelector('[title]');
expect(chip?.getAttribute('title')).toBe('Brief an Oma');
});
it('remove button has a minimum 44px touch target', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
const btn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/);
});
});

View File

@@ -1,33 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize';
import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
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';
let { data }: { data: PageData } = $props();
const g = $derived(data.geschichte);
const sanitized = $derived(safeHtml(g.body));
const isJourney = $derived(g.type === 'JOURNEY');
const publishedAt = $derived.by(() => {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'long');
});
function authorName(): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
const authorName = $derived(formatAuthorDisplayName(g.author));
const confirm = getConfirmService();
let deleteError = $state<string | null>(null);
async function handleDelete() {
deleteError = null;
const ok = await confirm.confirm({
title: m.geschichte_delete_confirm_title(),
body: m.geschichte_delete_confirm_body(),
@@ -39,105 +41,92 @@ async function handleDelete() {
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
if (res.ok) {
goto('/geschichten');
} else {
const err = await parseBackendError(res);
deleteError = getErrorMessage(err?.code);
}
}
</script>
<div class="mx-auto max-w-3xl px-4 py-8">
<div class="mx-auto max-w-7xl px-4 py-8">
<div class="mb-6">
<BackButton />
</div>
<article aria-labelledby="geschichte-title">
<header class="mb-6">
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
{g.title}
</h1>
<p class="font-sans text-sm text-ink-3">
{authorName()}
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
</p>
</header>
<article
aria-labelledby="geschichte-title"
class="rounded-sm border border-line bg-sheet px-5 py-6 shadow-sm sm:px-10 sm:py-10"
>
<div class="mx-auto max-w-3xl">
<header class="mb-6">
{#if isJourney}
<span
class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-xs font-bold tracking-widest text-journey uppercase"
>
{m.journey_badge_detail()}
</span>
{/if}
<h1 id="geschichte-title" class="mb-4 font-serif text-3xl leading-tight text-ink">
{g.title}
</h1>
<div class="mb-4 flex items-center gap-3 border-b border-line-2 pb-4">
{#if authorName}
<span
aria-hidden="true"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
style="background-color: {personAvatarColor(authorName)}"
>
{getInitials(authorName)}
</span>
{/if}
<div>
{#if authorName}
<p class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</p>
{/if}
{#if publishedAt}
<p class="font-sans text-xs text-ink-3">
{#if isJourney}
{m.journey_compiled_on({ date: publishedAt })}
{:else}
{m.geschichten_published_on({ date: publishedAt })}
{/if}
</p>
{/if}
</div>
{#if data.canBlogWrite}
<div class="ml-auto flex items-center gap-3">
<a
href="/geschichten/{g.id}/edit"
class="inline-flex h-11 items-center rounded border border-line bg-surface px-3 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>
</header>
<!--
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.
{#if deleteError}
<p
role="alert"
class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger"
>
{deleteError}
</p>
{/if}
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}
{#if isJourney}
<JourneyReader geschichte={g} />
{:else}
<StoryReader geschichte={g} />
{/if}
</div>
</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 -->
{#if g.documents && g.documents.length > 0}
<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.documents as d (d.id)}
<li>
<a
href="/documents/{d.id}"
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
>
{d.title}
{#if d.documentDate}
<span class="ml-2 font-sans text-xs text-ink-3">
{formatDate(d.documentDate, 'short')}
</span>
{/if}
</a>
</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>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import JourneyEditor from '$lib/geschichte/JourneyEditor.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
@@ -12,12 +13,13 @@ let { data }: { data: PageData } = $props();
let submitting = $state(false);
let errorMessage: string | null = $state(null);
const isJourney = $derived(data.geschichte.type === 'JOURNEY');
async function handleSubmit(payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) {
submitting = true;
errorMessage = null;
@@ -30,9 +32,17 @@ async function handleSubmit(payload: {
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
throw new Error('save failed');
}
goto(`/geschichten/${data.geschichte.id}`);
} catch (e) {
if (!errorMessage) {
console.error('Geschichte save failed', e);
errorMessage = getErrorMessage(undefined);
}
// Contract: onSubmit rejects on failure — both editors catch and keep
// their dirty state instead of disarming the unsaved-changes guard.
throw e;
} finally {
submitting = false;
}
@@ -45,7 +55,8 @@ async function handleSubmit(payload: {
</div>
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
{m.btn_edit()}: {data.geschichte.title}
{isJourney ? m.journey_edit_title_journey() : m.journey_edit_title_story()}:
{data.geschichte.title}
</h1>
{#if errorMessage}
@@ -57,5 +68,13 @@ async function handleSubmit(payload: {
</div>
{/if}
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
{#if isJourney}
<JourneyEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
{:else}
<GeschichteEditor
geschichte={data.geschichte}
onSubmit={handleSubmit}
submitting={submitting}
/>
{/if}
</div>

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
@@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte');
afterEach(cleanup);
const baseData = (overrides: Record<string, unknown> = {}) => ({
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: true,
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED',
type: 'STORY' as 'STORY' | 'JOURNEY',
persons: [],
documents: []
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
},
...overrides
});
@@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => {
const inputs = document.querySelectorAll('input, textarea, [contenteditable]');
expect(inputs.length).toBeGreaterThan(0);
});
it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByText(m.journey_add_document())).toBeVisible();
expect(document.querySelector('[role="toolbar"]')).toBeNull();
});
it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByRole('toolbar')).toBeVisible();
await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument();
});
});

View File

@@ -1,29 +1,58 @@
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
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 { csrfFetch } from '$lib/shared/cookies';
import { goto } from '$app/navigation';
import type { components } from '$lib/generated/api';
const { default: GeschichtePage } = await import('./+page.svelte');
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
});
const baseGeschichte = (overrides: Record<string, unknown> = {}) => ({
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>',
publishedAt: '2026-04-15T10:00:00Z' as string | null,
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as {
firstName?: string;
lastName?: string;
email: string;
} | null,
persons: [] as { id: string; displayName: string }[],
documents: [] as { id: string; title: string; documentDate?: string | null }[],
type: 'STORY',
status: 'PUBLISHED',
author: { id: 'u1', displayName: 'Anna Schmidt' },
persons: [],
items: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
publishedAt: '2026-04-15T10:00:00Z',
...overrides
});
const baseData = (overrides: Record<string, unknown> = {}) => ({
user: undefined,
canWrite: false,
canAnnotate: false,
geschichte: baseGeschichte(),
canBlogWrite: false,
...overrides
@@ -41,6 +70,46 @@ describe('geschichten/[id] page', () => {
.toBeVisible();
});
it('spans the directory width with a centered reading column inside the sheet (#799)', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
const outer = document.querySelector('[class*="mx-auto"]');
expect(outer!.className).toContain('max-w-7xl');
const column = document.querySelector('article [class*="max-w-3xl"]');
expect(column).not.toBeNull();
expect(column!.className).toContain('mx-auto');
});
it('renders the article on a reading-sheet surface card (#797)', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
const article = document.querySelector('article');
expect(article).not.toBeNull();
// bg-sheet sits between the sand canvas and the white cards inside the article
for (const cls of ['bg-sheet', 'border-line', 'rounded-sm', 'shadow-sm']) {
expect(article!.className).toContain(cls);
}
});
it('journey badge uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
});
const badge = document.querySelector('h1')!.parentElement!.querySelector('span');
expect(badge!.className).toContain('bg-journey-tint');
expect(badge!.className).toContain('text-journey');
expect(badge!.className).not.toContain('bg-orange-50');
});
it('renders the author full name from firstName + lastName', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
@@ -50,14 +119,12 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
});
it('falls back to author email when no name is set', async () => {
it('renders the server-computed author displayName verbatim', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' }
})
geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } })
})
}
});
@@ -65,10 +132,10 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText(/fallback@example.com/)).toBeVisible();
});
it('renders an empty author when author is null', async () => {
it('renders an empty author when author is absent', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) }
props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) }
});
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
@@ -86,7 +153,9 @@ describe('geschichten/[id] page', () => {
it('omits the publishedAt suffix when publishedAt is null', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) }
props: {
data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) })
}
});
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
@@ -108,8 +177,8 @@ describe('geschichten/[id] page', () => {
data: baseData({
geschichte: baseGeschichte({
persons: [
{ id: 'p1', displayName: 'Helene Schmidt' },
{ id: 'p2', displayName: 'Karl Müller' }
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
]
})
})
@@ -130,13 +199,20 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
});
it('renders the documents section when there are linked documents', async () => {
it('renders the documents section when there are linked journey items', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
documents: [{ id: 'd1', title: 'Brief 1923', documentDate: '1923-04-15' }]
items: [
{
id: 'item1',
position: 0,
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'DAY', receiverCount: 0 },
note: 'Brief aus 1923'
}
]
})
})
}
@@ -144,6 +220,27 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
await expect.element(page.getByText('Brief 1923')).toBeVisible();
await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
expect(document.querySelector('a[href="/documents/d1"]')).not.toBeNull();
});
it('JOURNEY shows "zusammengestellt am" instead of "veröffentlicht am"', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
});
await expect.element(page.getByText(/zusammengestellt am/i)).toBeVisible();
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
});
it('renders the author avatar initials in the meta bar', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect.element(page.getByText('AS', { exact: true })).toBeInTheDocument();
});
it('renders edit and delete actions when canBlogWrite is true', async () => {
@@ -167,4 +264,77 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
});
it('STORY with items:[] renders rich-text body and no empty-state message', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) }
});
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
});
it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
type: undefined as unknown as 'STORY' | 'JOURNEY',
items: []
})
})
}
});
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
});
it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => {
vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 }));
const confirmService = createConfirmService();
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, confirmService]]),
props: { data: baseData({ canBlogWrite: true }) }
});
// Trigger delete — opens confirm dialog
const deleteBtn = page.getByRole('button', { name: /löschen/i });
await userEvent.click(deleteBtn);
// Settle the confirmation dialog
confirmService.settle(true);
// Wait for the async delete to complete, then check goto was called
await vi.waitFor(() => {
expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten');
});
});
it('delete failure: shows error message when DELETE returns non-ok', async () => {
vi.mocked(csrfFetch).mockResolvedValue(
new Response(JSON.stringify({ code: 'FORBIDDEN' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
);
const confirmService = createConfirmService();
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, confirmService]]),
props: { data: baseData({ canBlogWrite: true }) }
});
// Trigger delete — opens confirm dialog
const deleteBtn = page.getByRole('button', { name: /löschen/i });
await userEvent.click(deleteBtn);
// Settle the confirmation dialog
confirmService.settle(true);
// Wait for the error to appear inline
await expect.element(page.getByRole('alert')).toBeVisible();
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
});

View File

@@ -10,24 +10,21 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId');
const documentId = url.searchParams.get('documentId');
const [personResult, documentResult] = await Promise.all([
personId
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: Promise.resolve(null),
documentId
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
: Promise.resolve(null)
]);
const personResult = personId
? await api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: null;
// Silently ignore 404/403 to avoid leaking entity existence on unknown IDs.
const initialPersons =
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
const initialDocuments =
documentResult && documentResult.response.ok && documentResult.data
? [documentResult.data]
: [];
return { initialPersons, initialDocuments };
// 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 };
};

View File

@@ -1,43 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import TypeSelector from './TypeSelector.svelte';
import StoryCreate from './StoryCreate.svelte';
import JourneyCreate from './JourneyCreate.svelte';
import type { PageData } from './$types';
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[];
documentIds: 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>
<div class="mx-auto max-w-5xl px-4 py-8">
@@ -47,19 +17,11 @@ async function handleSubmit(payload: {
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
{#if errorMessage}
<div
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
role="alert"
>
{errorMessage}
</div>
{#if data.selectedType === 'STORY'}
<StoryCreate initialPersons={data.initialPersons} />
{:else if data.selectedType === 'JOURNEY'}
<JourneyCreate />
{:else}
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
{/if}
<GeschichteEditor
initialPersons={data.initialPersons}
initialDocuments={data.initialDocuments}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
let title = $state('');
let titleTouched = $state(false);
let submitting = $state(false);
let errorMessage: string | null = $state(null);
const titleEmpty = $derived(title.trim().length === 0);
const showTitleError = $derived(titleEmpty && titleTouched);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
titleTouched = true;
if (titleEmpty) return;
submitting = true;
errorMessage = null;
try {
const res = await csrfFetch('/api/geschichten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
type: 'JOURNEY',
status: 'DRAFT',
body: '',
personIds: []
})
});
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
}
const created = await res.json();
goto(`/geschichten/${created.id}/edit`);
} catch (e) {
console.error('JourneyCreate submit failed', e);
errorMessage = getErrorMessage(undefined);
} finally {
submitting = false;
}
}
</script>
<form onsubmit={handleSubmit} class="max-w-lg">
{#if errorMessage}
<div
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
role="alert"
>
{errorMessage}
</div>
{/if}
<div class="mb-4">
<input
type="text"
bind:value={title}
maxlength="255"
onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError}
class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError
? 'border-danger'
: 'border-line'}"
/>
{#if showTitleError}
<p class="mt-1 font-sans text-xs text-danger">{m.geschichte_editor_title_required()}</p>
{/if}
</div>
<div class="flex items-center gap-4">
<button
type="submit"
disabled={submitting}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
>
{m.journey_create_submit()}
</button>
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
{m.journey_placeholder_back()}
</a>
</div>
</form>

View File

@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
const { default: JourneyCreate } = await import('./JourneyCreate.svelte');
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyCreate — failure path', () => {
it('renders the mapped error message when POST /api/geschichten fails with a code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR'));
});
it('navigates to the edit page on success', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'g-new' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
await vi.waitFor(() => {
expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit');
});
});
it('shows an error alert when the network request rejects (no crash)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
consoleError.mockRestore();
});
it('has an accessible label on the title input', async () => {
vi.stubGlobal('fetch', vi.fn());
render(JourneyCreate, {});
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,58 @@
<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);
throw new Error('create failed');
}
const created = await res.json();
goto(`/geschichten/${created.id}`);
} catch (e) {
if (!errorMessage) {
console.error('Story create failed', e);
errorMessage = getErrorMessage(undefined);
}
// Contract: onSubmit rejects on failure — GeschichteEditor catches and
// keeps its dirty state instead of disarming the unsaved-changes guard.
throw e;
} 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} />

View File

@@ -0,0 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import StoryCreate from './StoryCreate.svelte';
afterEach(() => cleanup());
describe('StoryCreate — document panel guard (#795)', () => {
it('renders without the document panel — documents attach after the first save', async () => {
render(StoryCreate, { initialPersons: [] });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});

View 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>

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

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

View File

@@ -20,51 +20,87 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte');
afterEach(cleanup);
const baseData = {
const baseData = (overrides: Record<string, unknown> = {}) => ({
initialPersons: [] as { id: string; displayName: string }[],
initialDocuments: [] as { id: string; title: string }[]
};
selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null,
...overrides
});
describe('geschichten/new page', () => {
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();
});
it('renders a button (BackButton component)', async () => {
render(GeschichtenNewPage, { props: { data: baseData } });
render(GeschichtenNewPage, { props: { data: baseData() } });
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBeGreaterThan(0);
});
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();
});
it('renders the GeschichteEditor child component', async () => {
render(GeschichtenNewPage, { props: { data: baseData } });
it('renders the GeschichteEditor when selectedType is STORY', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } });
// Editor renders inputs/textarea — verify at least one form input is present
const inputs = document.querySelectorAll('input, textarea');
expect(inputs.length).toBeGreaterThan(0);
});
it('passes initialPersons and initialDocuments through to the editor', async () => {
it('passes initialPersons through to the editor', async () => {
render(GeschichtenNewPage, {
props: {
data: {
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }],
initialDocuments: [{ id: 'd1', title: 'Brief 1923' }]
}
data: baseData({
selectedType: 'STORY',
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
})
}
});
// Both should appear somewhere in the rendered editor
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByText('Brief 1923')).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 JourneyCreate form when selectedType is JOURNEY', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible();
});
it('JOURNEY create form 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');
});
});

View File

@@ -0,0 +1,193 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
beforeEach(() => vi.clearAllMocks());
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
function makeUrl(params: Record<string, string | string[]> = {}) {
const url = new URL('http://localhost/geschichten');
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v));
} else {
url.searchParams.set(key, value);
}
}
return url;
}
function callLoad(url: URL) {
return load({
url,
request: new Request('http://localhost/geschichten'),
fetch: vi.fn() as unknown as typeof fetch
});
}
function mockApi(
opts: {
listData?: unknown[];
docOk?: boolean;
docData?: Record<string, unknown> | null;
} = {}
) {
const {
listData = [],
docOk = true,
docData = { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' }
} = opts;
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/api/documents/{id}') {
return Promise.resolve({
response: { ok: docOk, status: docOk ? 200 : 404 },
data: docOk ? docData : undefined
});
}
return Promise.resolve({
response: { ok: true, status: 200 },
data: listData
});
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
return mockGet;
}
describe('geschichten page load — documentFilter title resolution', () => {
it('resolves document title when documentId is a valid UUID and document exists', async () => {
mockApi({ docData: { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } });
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'Brief an Oma' });
});
it('falls back to originalFilename when document title is null', async () => {
mockApi({ docData: { id: VALID_UUID, title: null, originalFilename: 'scan_001.jpg' } });
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'scan_001.jpg' });
});
it('preserves an empty-string title rather than falling back to filename', async () => {
mockApi({ docData: { id: VALID_UUID, title: '', originalFilename: 'scan_001.jpg' } });
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: '' });
});
it('degrades to {id, title: null} on 404 without throwing (resolves, never rejects)', async () => {
// Explicit .resolves locks the no-throw guarantee — if error() were called, this would reject
mockApi({ docOk: false });
await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({
documentFilter: { id: VALID_UUID, title: null }
});
});
it('treats 403 identically to 404 — no oracle, loader still resolves', async () => {
// Permanent regression test: loader must not call getErrorMessage/throw on a forbidden title fetch.
// If it did, this assertion would fail with a rejection instead of a resolution.
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/api/documents/{id}') {
return Promise.resolve({ response: { ok: false, status: 403 }, data: undefined });
}
return Promise.resolve({ response: { ok: true, status: 200 }, data: [] });
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({
documentFilter: { id: VALID_UUID, title: null }
});
});
it('list still populates when title fetch returns 404 (independent results)', async () => {
mockApi({
listData: [{ id: 'g1', title: 'Some Story' }],
docOk: false
});
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.geschichten).toHaveLength(1);
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: null });
});
it('returns null documentFilter when documentId is syntactically invalid', async () => {
mockApi();
const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
expect(result.documentFilter).toBeNull();
});
it('does not fetch document title when documentId is invalid', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
expect(mockGet).not.toHaveBeenCalledWith('/api/documents/{id}', expect.anything());
});
it('returns null documentFilter when documentId is absent', async () => {
mockApi();
const result = await callLoad(makeUrl());
expect(result.documentFilter).toBeNull();
});
it('passes valid documentId to the geschichten API', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(mockGet).toHaveBeenCalledWith(
'/api/geschichten',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ documentId: VALID_UUID })
})
})
);
});
it('omits documentId from the list API when the value is not a valid UUID', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
const listCall = mockGet.mock.calls.find((c) => c[0] === '/api/geschichten');
expect(listCall?.[1]?.params?.query?.documentId).toBeUndefined();
});
it('keeps forwarding personId filters alongside documentId', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: VALID_UUID, personId: [VALID_UUID] }));
expect(mockGet).toHaveBeenCalledWith(
'/api/geschichten',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ documentId: VALID_UUID, personId: [VALID_UUID] })
})
})
);
});
});

View File

@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): PageData {
} as unknown as PageData;
}
function makeDocumentFilter(overrides: { id?: string; title?: string | null } = {}): {
id: string;
title: string | null;
} {
return {
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
title: 'Brief an Oma',
...overrides
};
}
describe('geschichten page — multi-person filter chips', () => {
it('renders one chip per person in personFilters', async () => {
render(Page, {
@@ -81,9 +92,12 @@ describe('geschichten page — multi-person filter chips', () => {
})
});
await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click();
const chipBtn = (await page
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
.element()) as HTMLElement;
chipBtn.click();
expect(goto).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).toContain('personId=b');
expect(url).not.toContain('personId=a');
@@ -91,6 +105,19 @@ describe('geschichten page — multi-person filter chips', () => {
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 () => {
render(Page, {
data: makeData({
@@ -100,6 +127,207 @@ describe('geschichten page — multi-person filter chips', () => {
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
});
describe('document filter chip', () => {
it('renders the document chip when documentFilter is set', async () => {
render(Page, {
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
});
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
});
it('does not render the document chip when documentFilter is null', async () => {
render(Page, { data: makeData() });
await expect.element(page.getByText(/Gefiltert nach Brief/)).not.toBeInTheDocument();
});
it('clicking the document chip remove button navigates without documentId', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
});
const removeBtn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
removeBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).not.toContain('documentId');
});
it('document chip removal preserves active person filters', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?personId=p1&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({
personFilters: [person('p1', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
const removeBtn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
removeBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).toContain('personId=p1');
expect(url).not.toContain('documentId');
});
it('marks the "All" pill as unpressed when document filter is active', async () => {
render(Page, {
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
});
await expect
.element(page.getByRole('button', { name: 'Alle' }))
.toHaveAttribute('aria-pressed', 'false');
});
});
it('removing a person chip preserves an active document filter in the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
const chipBtn = (await page
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
.element()) as HTMLElement;
chipBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).not.toContain('personId=a');
expect(url).toContain('documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
window.history.replaceState({}, '', '/');
});
it('clearAll removes both person and document filters from the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
const allBtn = (await page.getByRole('button', { name: 'Alle' }).element()) as HTMLElement;
allBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).not.toContain('personId');
expect(url).not.toContain('documentId');
window.history.replaceState({}, '', '/');
});
describe('empty state precedence', () => {
it('shows geschichten_empty_for_document when only document filter is active', async () => {
render(Page, {
data: makeData({
geschichten: [],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
await expect.element(page.getByText('Noch keine Geschichten zu diesem Brief')).toBeVisible();
});
it('shows geschichten_empty_for_persons when only person filter is active', async () => {
render(Page, {
data: makeData({
geschichten: [],
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
})
});
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
});
it('shows geschichten_empty_no_filter when no filter is active', async () => {
render(Page, { data: makeData({ geschichten: [] }) });
await expect
.element(page.getByText('Es gibt noch keine veröffentlichten Geschichten.'))
.toBeVisible();
});
it('person-wins: shows persons message when both person and document filters are active', async () => {
render(Page, {
data: makeData({
geschichten: [],
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
await expect
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
.not.toBeInTheDocument();
});
it('chip renders alongside results (empty state not shown when results exist)', async () => {
render(Page, {
data: makeData({
geschichten: [
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
] as PageData['geschichten'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
await expect.element(page.getByText(/Lesereise Berlin/)).toBeVisible();
await expect
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
.not.toBeInTheDocument();
});
});
it('renders all filter pills with a 44px touch target (h-11)', async () => {
render(Page, {
data: makeData({

View File

@@ -26,7 +26,7 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
title: string;
body?: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
author?: { firstName?: string; lastName?: string };
}>,
personFilters: [] as { id?: string; displayName: string }[],
documentFilter: null,
@@ -35,6 +35,14 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
});
describe('geschichten/+ page', () => {
it('uses the same directory width as Dokumente/Personen overviews (max-w-7xl)', async () => {
render(GeschichtenListPage, { props: { data: baseData() } });
const container = document.querySelector('[class*="mx-auto"]');
expect(container).not.toBeNull();
expect(container!.className).toContain('max-w-7xl');
});
it('renders the page heading', async () => {
render(GeschichtenListPage, { props: { data: baseData() } });
@@ -127,7 +135,7 @@ describe('geschichten/+ page', () => {
title: 'Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
publishedAt: '2026-04-15T10:00:00Z',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
author: { firstName: 'Anna', lastName: 'Schmidt' }
}
]
})
@@ -139,7 +147,7 @@ describe('geschichten/+ page', () => {
.toBeVisible();
});
it('authorName falls back to email when first/last names are missing', async () => {
it('authorName falls back to [Unbekannt] when first/last names are missing', async () => {
render(GeschichtenListPage, {
props: {
data: baseData({
@@ -147,14 +155,14 @@ describe('geschichten/+ page', () => {
{
id: 'g1',
title: 'Anonym',
author: { email: 'anon@example.com' }
author: {}
}
]
})
}
});
expect(document.body.textContent).toContain('anon@example.com');
expect(document.body.textContent).toContain('[Unbekannt]');
});
it('authorName renders empty when author is undefined', async () => {
@@ -178,7 +186,7 @@ describe('geschichten/+ page', () => {
{
id: 'g1',
title: 'Draft',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
author: { firstName: 'Anna', lastName: 'Schmidt' }
}
]
})
@@ -188,8 +196,9 @@ describe('geschichten/+ page', () => {
// No "·" separator before date when no publishedAt
const titleHeading = document.querySelector('h2');
const card = titleHeading?.closest('li');
// The middle paragraph (author line) should not contain "·"
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 () => {
@@ -201,7 +210,7 @@ describe('geschichten/+ page', () => {
id: 'g1',
title: 'No Body',
body: '',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
author: { firstName: 'Anna', lastName: 'Schmidt' }
}
]
})

View File

@@ -34,6 +34,9 @@
--color-overlay: var(--c-overlay);
--color-muted: var(--c-muted);
/* Reading sheet — article panel between canvas and the white cards it contains */
--color-sheet: var(--c-sheet);
/* Borders */
--color-line: var(--c-line);
--color-line-2: var(--c-line-2);
@@ -77,6 +80,21 @@
--color-warning: #b45309;
--color-warning-fg: #ffffff;
/* Warning surface — amber banner (bg/border/text), mode-aware */
--color-warning-bg: var(--c-warning-bg);
--color-warning-border: var(--c-warning-border);
--color-warning-text: var(--c-warning-text);
/* 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);
/* Interlude row — neutral surface with left accent border; ZWISCHENTEXT label */
--color-interlude-bg: var(--c-interlude-bg);
--color-interlude-border: var(--c-interlude-border);
--color-interlude-label: var(--c-interlude-label);
/* Static brand tokens (not themed) */
--color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint);
@@ -91,6 +109,7 @@
--c-surface: #ffffff;
--c-overlay: #ffffff;
--c-muted: #f5f4ef;
--c-sheet: #fafaf7; /* between canvas and surface — spec .g-article value */
--c-line: #e4e2d7;
--c-line-2: #eeede8;
@@ -128,6 +147,22 @@
/* Parchment — warm near-white for example blocks (light mode: cream #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;
/* Interlude (Zwischentext) — neutral warm surface with left accent border */
--c-interlude-bg: #f5f4f0;
--c-interlude-border: #a1dcd8;
--c-interlude-label: #4b5563;
/* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */
--c-warning-bg: #fffbeb;
--c-warning-border: #fcd34d;
--c-warning-text: #92400e;
/* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d;
@@ -182,6 +217,7 @@
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-sheet: #011222; /* between canvas and surface */
--c-line: #0d3358;
--c-line-2: #092843;
@@ -246,6 +282,23 @@
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
visibility parity with the 8% light-mode token. Decorative carve-out. */
--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;
/* Interlude (Zwischentext) — KEEP IN SYNC with :root[data-theme='dark'] */
--c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5;
/* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓
KEEP IN SYNC with :root[data-theme='dark'] */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
}
}
@@ -258,6 +311,7 @@
--c-surface: #011526;
--c-overlay: #011e38;
--c-muted: #011a30;
--c-sheet: #011222; /* between canvas and surface */
--c-line: #0d3358;
--c-line-2: #092843;
@@ -321,6 +375,21 @@
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
--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;
/* Interlude (Zwischentext) — KEEP IN SYNC with the @media block above */
--c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5;
/* Warning surface — KEEP IN SYNC with the @media block above */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */