diff --git a/docs/specs/lesereisen-editor-spec.html b/docs/specs/lesereisen-editor-spec.html new file mode 100644 index 00000000..6b9ea590 --- /dev/null +++ b/docs/specs/lesereisen-editor-spec.html @@ -0,0 +1,808 @@ + + +
+ + +Kuratierungs-Oberfläche für JourneyEditor auf /geschichten/[id]/edit (wenn type === 'JOURNEY'). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.
BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.
+Der JourneyEditor ist eine parallele Implementierung zum bestehenden GeschichteEditor und wird auf derselben Edit-Route eingeblendet wenn type === 'JOURNEY'. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.
Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (body), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.
Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.
+Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.
+Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).
+Varianten: Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste
+ +| Element | Wert | Hinweise |
|---|---|---|
| Seitenstruktur | ||
| Bedingte Verzweigung | {#if geschichte.type === 'JOURNEY'}<JourneyEditor />{:else}<GeschichteEditor />{/if} | in edit/+page.svelte; Props: geschichte: Geschichte |
| Split-Layout | flex flex-1 overflow-hidden (gleich wie GeschichteEditor) | 70/30; Sidebar identisch |
| Topbar-Badge | „REISE" Pill neben dem Titel-Label | orange; kein interaktives Element; zeigt Typ |
| Titel-Input | ||
| Titel-Input | font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none | bind:value={title}; gleiche Validierung wie GeschichteEditor (required) |
| Einleitungs-Textarea | ||
| Intro-Textarea | font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1 | bind:value={body}; plaintext; auto-resize per rows-attr oder JS |
| Label | text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1 | „EINLEITUNG (OPTIONAL)" |
| Leerstate | ||
| Leerstate-Container | py-8 text-center border border-dashed border-line rounded-sm bg-surface | verschwindet sobald erstes Item vorhanden |
| Leerstate-Text | font-serif text-xs text-ink-3 italic | |
| Veröffentlichen-Button | ||
| Disabled-Zustand | disabled={items.length === 0 || !title.trim()} | opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es |
Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.
+Varianten: Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile
+ +| Element | Wert | Hinweise |
|---|---|---|
| Item-Zeile allgemein | ||
| Item-Container | flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden | interlude: bg-orange-50 border-orange-200 |
| Drag-Handle | w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0 | aria-label="Reihenfolge ändern"; cursor-grabbing während Drag |
| Positions-Nr. | w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0 | aus Array-Index, nicht item.position |
| Entfernen-Button | w-6 flex items-start justify-center pt-2 shrink-0 | × aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden |
| Dokument-Item | ||
| Brieftitel | text-[11px] font-semibold text-ink leading-snug mb-0.5 | document.title |
| Briefmeta | text-xs text-ink-3 | formatDate(doc.documentDate) · "von X" oder "von X an Y" |
| Notiz-Textarea (sichtbar) | w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2 | auto-expand; bind:value={item.note} |
| „Notiz hinzufügen" Link | text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1 | togglet Notiz-Textarea |
| „Notiz entfernen" Link | text-xs text-ink-3 inline-flex items-center gap-1 mt-1 | zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus |
| Interlude-Item | ||
| Interlude-Container | bg-orange-50 border-orange-200 (überschreibt Item-Container) | kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl |
| Label „Zwischentext" | text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1 | immer sichtbar; nicht editierbar |
| Zwischentext-Textarea | w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400 | bind:value={item.note}; auto-expand; min 44px für Touch-Target |
| Aktionsleiste | ||
| Add Bar | flex gap-2 pt-2 pb-1 | immer unten sichtbar, auch wenn Liste gefüllt |
| „Brief hinzufügen" Button | border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1 | öffnet existierende DocumentPicker-Komponente als Dropdown/Modal |
| „Zwischentext hinzufügen" Button | gleich wie Brief-Button | fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea |
| Drag-to-Reorder | ||
| Bibliothek | @dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen) | kein neues Package ohne Absprache |
| Reorder-API-Call | PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items | nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren |
| Accessibility | Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht | WCAG 2.1 SC 2.1.1 |
Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.
+Inset-Ansicht — kein vollständiger Seiten-Mockup nötig
+ +| Element | Wert | Hinweise |
|---|---|---|
| Toggleverhalten | ||
| Lokaler State | let noteOpen = item.note !== null and item.note !== '' | öffnet sich automatisch wenn Notiz bereits vorhanden |
| „Notiz hinzufügen" Klick | noteOpen = true; tick().then(() => noteTextarea.focus()) | Fokus nach Svelte-Tick um DOM-Update abzuwarten |
| Textarea blur-Handler | on:blur={() => saveNote(item.id, note)} | PATCH /api/geschichten/{id}/items/{itemId} mit {note} |
| Leere Notiz on blur | wenn note.trim() === '' → noteOpen = false; note = null | verhindert leere Notizen im Backend |
| Fokus-Styling | ||
| Fokus-Ring | focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white | sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand |
| Spar-Hint | text-[9px] text-ink-3 mt-1 | „Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false |
| Barrierefreiheit | ||
| aria-label Textarea | aria-label="Kuratoren-Notiz für {document.title}" | spezifisch; Screen-Reader nennt Brief-Kontext |
| aria-expanded Toggle | aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button | kommuniziert Expand-State |
Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.
+Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.
+ +| Element | Wert | Hinweise |
|---|---|---|
| Layout-Anpassungen | ||
| Split entfällt | @media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende | gleich wie GeschichteEditor auf Mobile |
| Collapsibles | details/summary oder eigene boolean-Toggle; Personen + Status separat | geschlossen beim ersten Laden; Fokus öffnet |
| Touch & Drag | ||
| Drag auf Mobile | Long-Press (500ms) auf dem Drag-Handle aktiviert Drag | dnd-kit unterstützt Touch nativ; kein separates Config nötig |
| Touch Target Items | min-h-[44px] für jede Item-Zeile | WCAG 2.2 AA; durch Padding gesichert |
| Add-Buttons | flex-1; volle verfügbare Breite geteilt | min-h-[44px] als Touch-Target |
| Savebar | ||
| Savebar Mobile | flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf" | Volltext passt nicht auf 320px |
| Datei | Typ | Beschreibung |
|---|---|---|
src/lib/geschichte/JourneyEditor.svelte | Svelte-Komponente | Hauptkomponente; Props: geschichte: Geschichte |
src/lib/geschichte/JourneyItemRow.svelte | Svelte-Komponente | Eine Zeile (Dokument oder Interlude); Props: item: JourneyItem, position: number, Events: remove, noteChange |
GeschichteEditor.svelte erhält ein neues Prop type: GeschichteType.type === 'JOURNEY': rendere JourneyEditor statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.+page.svelte) verankert — JourneyEditor gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.| Aktion | Endpoint | Body |
|---|---|---|
| Brief hinzufügen | POST /api/geschichten/{id}/items | {documentId: UUID} |
| Zwischentext hinzufügen | POST /api/geschichten/{id}/items | {note: string} |
| Notiz speichern/bearbeiten | PATCH /api/geschichten/{id}/items/{itemId} | {note: string | null} |
| Item entfernen | DELETE /api/geschichten/{id}/items/{itemId} | — |
| Reihenfolge speichern | PUT /api/geschichten/{id}/items/reorder | [{id: UUID, position: number}] |
aria-live="polite"-Fehlerhinweis anzeigen.DocumentPicker-Komponente (prüfe $lib/document/ auf vorhandene Typeahead-Komponenten).POST /items mit documentId, neues Item wird an das Ende der Liste angehängt und eingeblendet.@dnd-kit/core oder svelte-dnd-action bereits im package.json ist. Kein neues Package einführen ohne Absprache.[{id, position}] berechnen (position = index * 10 lässt Lücken für künftige Inserts) und PUT /items/reorder senden.<ol>-Element — kommuniziert die Ordnung an Screenreader.role="button", tabindex="0", aria-label="Reihenfolge von '{title}' ändern".aria-label="'{title}' entfernen"; kein reines ×-Zeichen ohne Label.aria-label="Kuratoren-Notiz für '{title}'".focus-visible:ring-2 focus-visible:ring-primary auf allen Buttons und Textareas.Geschichte.body dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.Typauswahl bei /geschichten/new, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf /geschichten/[id] wenn type === 'JOURNEY'. Bestehende Story-Ansichten bleiben unverändert.
Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.
+Eine Lesereise ist eine Geschichte mit type === 'JOURNEY'. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (JourneyItems mit document_id) und Zwischentexten (JourneyItems ohne document_id). Das optionale Feld body dient als Einleitung/Preface.
Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf /geschichten/new als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.
Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.
+Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (?type=JOURNEY).
Varianten: Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt
+ +| Element | Wert | Hinweise |
|---|---|---|
| Layout | ||
| Selector area | flex flex-1 items-center justify-center bg-canvas px-6 py-10 | zentriert, füllt restliche Höhe |
| Frage | font-serif text-sm text-ink-2 text-center mb-4 | |
| Karten-Grid | flex gap-4 | 2 gleich breite Karten; auf Mobile flex-col |
| Type-Karte | ||
| Karte (inaktiv) | border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface | focus-visible:ring-2 focus-visible:ring-primary |
| Karte (ausgewählt) | border-2 border-orange-500 bg-orange-50 shadow-sm | aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange) |
| Check-Kreis | w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2 | nur sichtbar wenn ausgewählt |
| Kartentitel | font-serif text-sm text-ink | |
| Kartenbeschreibung | text-xs text-ink-3 leading-relaxed mt-1 | |
| Navigation | ||
| Weiter-Button | rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40 | disabled wenn keine Karte ausgewählt |
| URL-Param | ?type=STORY | ?type=JOURNEY | per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar |
| Mobile | flex-col Karten; volle Breite | kein Scrollbedarf auf 320px |
Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit type === 'STORY' bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.
Varianten: Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)
+ +| Element | Wert | Hinweise |
|---|---|---|
| Badge | ||
| Journey badge | inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200 | nur wenn type === 'JOURNEY' |
| Position Desktop | unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta) | kein extra Abstand nötig — gap-1 der Flex-Spalte reicht |
| Position Mobile | inline flex items-center gap-1.5 neben Titel | Titel + Badge in einem flex-Wrapper; badge shrink-0 |
| aria-label | aria-label="Lesereise" | Badge ist span, kein interaktives Element |
| Bedingte Logik | ||
| Svelte guard | {#if geschichte.type === 'JOURNEY'}<span …>REISE</span>{/if} | kein Badge für STORY |
Wenn type === 'JOURNEY' ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (body) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.
Varianten: Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile
+ +| Element | Wert | Hinweise |
|---|---|---|
| Seitenstruktur | ||
| Bedingte Logik | {#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if} | in +page.svelte von /geschichten/[id] |
| Artikel-Container | max-w-3xl mx-auto px-4 py-8 | gleich wie StoryReader |
| Journey-Badge | inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2 | über dem Titel; nicht für STORY |
| Titel | font-serif text-3xl text-ink leading-tight mb-4 | gleich wie Story |
| Metabar | flex items-center gap-3 pb-4 border-b border-subtle mb-4 | gleich wie Story |
| Bearbeiten/Löschen | nur BLOG_WRITE; auf Mobile im ··· BottomSheet | gleich wie Story |
| Intro-Absatz | ||
| Intro (body) | font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle | nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext |
| Dokument-Item | ||
| Item-Zeile | mb-3 | kein flex nötig — Karte ist full-width |
| Dokumentkarte | bg-white border border-line rounded-sm p-3 | |
| Brieftitel | font-serif text-sm text-ink leading-snug mb-0.5 | document.title |
| Briefmeta | text-xs text-ink-3 mb-2 | formatDate(document.documentDate) · "von X an Y" |
| Brief öffnen Link | inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary | href="/documents/{item.document.id}" |
| Kuratoren-Annotation | ||
| Annotation | mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2 | nur rendern wenn item.note vorhanden |
| Annotations-Text | text-xs italic text-ink-2 leading-relaxed | |
| Interlude-Item | ||
| Interlude-Block | pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4 | item.document === null |
| Interlude-Text | text-xs italic text-ink leading-relaxed | item.note; plaintext |
| Mobile | ||
| ··· Menü | ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen | BLOG_WRITE; gleich wie Story |
| Touch Target (Brief öffnen) | min-h-[44px] durch padding auf der Karte | WCAG 2.2 AA |
| View | Route | Änderung |
|---|---|---|
| Neue Geschichte | /geschichten/new | Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY |
| Geschichten-Liste | /geschichten | Journey-Badge in GeschichtenCard wenn type === 'JOURNEY' |
| Geschichte-Detail | /geschichten/[id] | Bedingte Verzweigung: JourneyReader | StoryReader |
JourneyReader.svelte — rendert Intro + Items-Liste; Props: geschichte: GeschichteDetailJourneyItemCard.svelte — ein Dokument-Item mit optionaler Annotation; Props: item: JourneyItem, position: numberJourneyInterlude.svelte — ein reiner Text-Interlude; Props: note: stringGeschichteType: 'STORY' | 'JOURNEY'JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }Geschichte.items — geordnete Liste (nach position ASC); für STORY leerGeschichte.body — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body/geschichten/new-Route — kein eigener URL, kein goto(). Zustand let selectedType: GeschichteType | null = null in der Komponente.selectedType !== null ist der „Weiter"-Button aktiviert (disabled={!selectedType}).selectedType === 'JOURNEY' → goto('/geschichten/new?type=JOURNEY') und zeige den Journey-Editor (aus Issue #753); wenn STORY → bestehender GeschichteEditor (unverändert).role="radio" und aria-checked für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.GeschichtenCard.svelte hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.aria-label="Lesereise" für den Badge-Span.ORDER BY position ASC). Keine client-seitige Sortierung nötig.item.document === null. In diesem Fall: JourneyInterlude-Komponente rendern.body) wird als Plaintext gerendert — nicht als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.currentUser.permissions.includes('BLOG_WRITE') — gleich wie Story.<ol> semantisch für die geordnete Briefliste. Interludes sind <li>-Elemente mit aria-label="Kuratorennotiz".aria-label, z.B. aria-label="Brief vom 12. Juli 1938 öffnen".focus-visible:ring-2 focus-visible:ring-primary auf allen Links.