# Lesereisen — Journey-Editor

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.

Familienarchiv
Final Spec
2026-06-07 · @leonievoss
Issue #753
E

## Journey-Editor

BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.

/geschichten/[id]/edit (type === 'JOURNEY')
Konzept

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.

Screens — Leerer Editor

### LE-1 — Journey-Editor leer

Issue #753 · LE-1

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

Desktop — 1040px · Neuer Entwurf
←
Neue Lesereise REISE
ENTWURF
Einleitung (optional)
Briefe & Zwischentexte
Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.
Alle Änderungen werden als Entwurf gespeichert.

#### impl-ref — LE-1 Leerer Editor

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

Screens — Editor mit Einträgen

### LE-2 — Journey-Editor mit Einträgen

Issue #753 · LE-2

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

Desktop — 1040px · VERÖFFENTLICHT
←
Lesereise bearbeiten REISE
VERÖFFENTLICHT
Löschen
Einleitung (optional)
Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.
Briefe & Zwischentexte
1
Brief vom 12. Juli 1938
12. Juli 1938 · von Franz Raddatz an Emma Müller
Notiz hinzufügen
×
Zwischentext
Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.
×
2
Postkarte aus Breslau, August 1938
22. Aug. 1938 · von Franz Raddatz an Emma Müller
Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?
Notiz entfernen
×
3
Brief vom 3. September 1939
3. Sept. 1939 · von Emma Müller an Franz Raddatz
Notiz hinzufügen
×
Änderungen sofort live — Leser sehen die aktuelle Version.

#### impl-ref — LE-2 Items-Liste

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 → `--color-interlude-bg` / `--color-interlude-border` CSS tokens
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 → `text-xs text-ink-3 underline hover:text-accent` | 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 → `--color-interlude-bg` left-accent border via `--color-interlude-border` | kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl
Label „Zwischentext" | text-orange-700 → `color: var(--color-interlude-label)` | immer sichtbar; nicht editierbar
Zwischentext-Textarea | border-orange-200 focus:border-orange-400 → `border-line focus-visible:ring-focus-ring` | 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 → `createBlockDragDrop<JourneyItemView>` aus `$lib/document/transcription/useBlockDragDrop.svelte` | kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt
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

Screens — Inline-Notiz-Editing

### LE-3 — Notiz-Textarea wird geöffnet

Issue #753 · LE-3

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

Inset — Notiz-Textarea geöffnet (Fokus)
1
Brief vom 12. Juli 1938
12. Juli 1938 · Franz → Emma
Notiz hinzufügen
×
2
Postkarte aus Breslau, August 1938
22. Aug. 1938 · Franz → Emma
|
Wird gespeichert, wenn du das Feld verlässt.
Notiz entfernen
×

#### impl-ref — LE-3 Inline-Notiz

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

Screens — Mobile Editor

### LE-4 — Mobile Journey-Editor

Issue #753 · LE-4

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.

Mobile — 320px · mit Einträgen
9:41●●●
← Lesereise bearbeiten
VERÖFF.
Brief vom 12. Juli 1938
12. Juli 1938 · Franz → Emma
×
Zwischentext
Im Sommer 1938 schrieb Franz voller Zuversicht…
×
Postkarte Aug. 1938
22. Aug. 1938 · Franz → Emma
Diese Karte ist ungewöhnlich kurz für Franz…
×
Personen ›
Status & Speichern ›

#### impl-ref — LE-4 Mobile

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 | Move-Up/Down Buttons statt Drag (44px touch targets) | dnd-kit unterstützt Touch nativ → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons
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

## Implementation Guide — Journey-Editor

### Neue Komponente

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`

### Edit-Page-Integration

- `GeschichteEditor.svelte` erhält ein neues Prop `type: GeschichteType`.
- Wenn `type === 'JOURNEY'`: rendere `JourneyEditor` statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.
- Die Savebar-Logik ist in der Edit-Page (`+page.svelte`) verankert — `JourneyEditor` gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.

### API-Calls

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}]`

### Optimistische Updates

- Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State sofort, der API-Call läuft parallel.
- Bei Fehler: lokalen State zurückrollen und einen `aria-live="polite"`-Fehlerhinweis anzeigen.
- Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.

### DocumentPicker-Integration

- Der „Brief hinzufügen"-Button öffnet die bestehende `DocumentPicker`-Komponente (prüfe `$lib/document/` auf vorhandene Typeahead-Komponenten).
- Nach Auswahl eines Dokuments: `POST /items` mit `documentId`, neues Item wird an das Ende der Liste angehängt und eingeblendet.
- Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.

### Drag-to-Reorder

- Bibliothek: prüfe zunächst ob `@dnd-kit/core` oder `svelte-dnd-action` bereits im `package.json` ist. → Implementiert mit `createBlockDragDrop<JourneyItemView>` (kein externes Package).
- Nach dem Drop: neue Reihenfolge als Array `[{id, position}]` berechnen (position = index * 10 lässt Lücken für künftige Inserts) und `PUT /items/reorder` senden.
- Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".

### Barrierefreiheit

- Items-Liste: `<ol>`-Element — kommuniziert die Ordnung an Screenreader.
- Drag-Handle: `role="button"`, `tabindex="0"`, `aria-label="Reihenfolge von '{title}' ändern"`.
- Entfernen-Button: `aria-label="'{title}' entfernen"`; kein reines ×-Zeichen ohne Label.
- Notiz-Textarea: `aria-label="Kuratoren-Notiz für '{title}'"`.
- Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).
- Fokusring: `focus-visible:ring-2 focus-visible:ring-primary` auf allen Buttons und Textareas.

### Abgrenzung zu GeschichteEditor

- TipTap wird für JOURNEY nicht geladen — kein unnötiger Bundle-Load.
- Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.
- Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.
- `Geschichte.body` dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.