7 Commits
Author SHA1 Message Date
hnzio 2851c7d936 Release 0.5.8 2026-10-23 07:47:27 +02:00
hnzio 22ae3a68d6 Release 0.5.7 2026-10-23 00:14:43 +02:00
hnzio dfa236390d Release 0.5.6 2026-10-22 20:54:46 +02:00
hnzio b2467b87f5 Release 0.5.5 2026-10-22 20:41:34 +02:00
hnzio 4fed048843 Release 0.5.4 2026-10-22 19:17:14 +02:00
hnzio f466df2c5c Release 0.5.3 2026-10-22 14:18:57 +02:00
hnzio 25da870601 Release 0.5.2 2026-10-22 13:51:30 +02:00
37 changed files with 3930 additions and 720 deletions

No files matched your search

+148
View File
@@ -189,3 +189,151 @@
Pixeln visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Redaktionelles Piece-Arbeitsblatt
| Kriterium | Nachweis | Stand |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Piece-Arbeitsblatt
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
1 von 1 Browsertest erfolgreich
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Oberflächenrelease 0.5.4
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------- | --------------------- |
| Klare Hierarchie in beiden Themes | Eingang, Fundus, Editor, Sendungsplan und Live-Steuerung in Light/Dark visuell kontrolliert | erfüllt am 22.10.2026 |
| Exakte heinzlive-Darkmode-Farben | berechnete Theme-Tokens und Screenshots der dunklen Arbeitsansichten geprüft | erfüllt am 22.10.2026 |
| Responsive Darstellung ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln auf allen gezielt geprüften Ansichten | erfüllt am 22.10.2026 |
| Sendungsplan-Aktionen bleiben erreichbar | drei sichtbare Aktionen einer Planzeile liegen waagerecht auf gleicher Höhe; Mobilbild geprüft | erfüllt am 22.10.2026 |
| Fokus und reduzierte Bewegung | sichtbarer Cyan-Fokusring und bestehende `prefers-reduced-motion`-Regeln im Stylesheet geprüft | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenrelease 0.5.4
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Chromium-Lauf für Eingang, Fundus, Piece-Editor, Sendungsplan und
Live-Steuerung bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne
Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop- und Mobilaufnahmen aller genannten Ansichten visuell kontrolliert
- vollständige Unit- und E2E-Matrix nicht erneut ausgeführt, weil dieser Release
ausschließlich Darstellung und redaktionelle Kurztexte verändert
- `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.5
| Kriterium | Nachweis | Stand |
| ------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------- |
| Nachtblau und Glaswirkung erkennbar | berechnete Transparenz/Unschärfe und Desktop-/Mobilaufnahmen in Darkmode geprüft | erfüllt am 22.10.2026 |
| Schreibfelder im Ruhezustand sichtbar | deckende Feldfarbe, feine Kontur, Innenabstand und Textcursor im Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Goldener Tastaturfokus | berechnete Fokusfarbe und Fokus-Schatten am Titelfeld geprüft; Cyanrahmen nicht mehr aktiv | erfüllt am 22.10.2026 |
| Inhaltsgerechte Feldhöhen | kurze Einleitung ist niedriger als ausführliche Moderationsfläche; beide bleiben skalierbar | erfüllt am 22.10.2026 |
| Lightmode und Smartphone erhalten | fünf Arbeitsansichten bei 1440 × 1000 und 390 × 844 Pixeln ohne Seitenüberlauf geprüft | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.5
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Chromium-Lauf in Light/Dark und Desktop/Mobil ohne Konsolenfehler
oder horizontalen Seitenüberlauf; Glas-, Feld- und Fokuswerte zusätzlich
programmatisch geprüft
- vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt
- `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.6
| Kriterium | Nachweis | Stand |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Gold-/Violettlicht und Glasflächen sichtbar | Fundus und Editor in Light/Dark auf Desktop und Mobil visuell sowie über berechnete Werte geprüft | erfüllt am 22.10.2026 |
| Schreibflächen ruhig und eindeutig abgegrenzt | deckende Feldfarbe, Kontur und goldener Fokus im geöffneten Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Neuanlage und Suche unmittelbar erreichbar | sichtbare Fundusaktionen sowie geöffnete Vorlagenauswahl auf Desktop und Mobil geprüft | erfüllt am 22.10.2026 |
| Zusatzfilter und Zeilenaktionen zugänglich | native Aufklappelemente für Filter und Aktionen per Browserinteraktion geprüft | erfüllt am 22.10.2026 |
| Funduszeilen per Tastatur zu öffnen | erster Titeleintrag fokussiert, mit Eingabetaste geöffnet und Editoraufruf geprüft | erfüllt am 22.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.6
- `npm run check`: 0 Fehler und 0 Warnungen
- gezielter Chromium-Lauf für Fundus und geöffneten Piece-Editor bei
1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne Konsolenfehler oder
horizontalen Seitenüberlauf
- Neuanlage, Zusatzfilter, Aktionsmenü, Tastaturöffnung, Glaswerte und goldener
Fokus automatisiert kontrolliert; alle erzeugten Desktop-/Mobilaufnahmen
visuell geprüft
- Produktionsbuild sowie vollständige Unit- und E2E-Matrix gemäß Auftrag nicht
ausgeführt
- `git diff --check`: erfolgreich
## Materialkorrektur 0.5.7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| Hintergrundlicht im Arbeitsbereich sichtbar | Fundus und Sendungsplanung ohne Popup in Dark/Light auf Desktop und Mobil visuell geprüft | erfüllt am 23.10.2026 |
| Popup erhält die Lichtstimmung | Editor mit leichtem `46%`-Backdrop und `6px` Blur gegen Ansicht ohne Popup verglichen | erfüllt am 23.10.2026 |
| Verschachtelte Ablaufkarten werden dunkler | berechnete RGBA-Werte von Außenglas, Abschnitt, Piece und Aktionsgruppe kontrolliert | erfüllt am 23.10.2026 |
| Graue Sonderflächen entfernt | aktive Tabs, private Recherche, Quellen-/Materialflächen und Aktionsgruppen visuell geprüft | erfüllt am 23.10.2026 |
| Schreibfelder bleiben ruhig und klar | deckende Feldfüllung, feine Kontur und unveränderter Goldfokus in beiden Editorbereichen geprüft | erfüllt am 23.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 23.10.2026 |
## Ausgeführte Prüfungen Materialkorrektur 0.5.7
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- berechnete Styles vor und nach der Korrektur für App-Hintergrund, Navigation,
Ablauf-Panel, Abschnitte, Pieces, Aktionsgruppen, Dialog, Backdrop, Tabs,
Rechercheflächen und Schreibfelder kontrolliert
- gezielter Chromium-Lauf für Sendungsplanung sowie Piece-Editor mit
„Sendungstext“ und „Recherche & Quellen“ bei 1440 × 1000 und 390 × 844 Pixeln
in Light/Dark ohne Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop-/Mobilaufnahmen mit und ohne Popup visuell verglichen
- vollständige Funktionstests gemäß Auftrag nicht ausgeführt
- `git diff --check`: erfolgreich
## Redaktionelle Arbeitsabläufe 0.5.8
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------- |
| Persistente unabhängige Piece-Vorlagen | Servicetests prüfen Snapshot, Revision, Neuanlage, Wiederverwendung und Löschen | erfüllt am 23.10.2026 |
| Atomare idempotente Direktanlage | Verhaltenstests prüfen Wiederholung derselben Befehls-ID und vollständigen Rollback | erfüllt am 23.10.2026 |
| Interner und öffentlicher Titel getrennt | Service- und Browserabläufe prüfen getrennte Werte, Snapshots und Fallback | erfüllt am 23.10.2026 |
| Entfernen betrifft nur konkrete Verwendung | Browser prüft Bestätigung, Rückgabe in Fundus und unverändertes Original | erfüllt am 23.10.2026 |
| Beliebige Live-Verwendung auswählbar | Verhaltenstests wählen ersten und erledigten Eintrag über stabile Usage-ID | erfüllt am 23.10.2026 |
| Stabiler widerrufbarer OBS-Kanal | Servicetests prüfen Wiederöffnung/Rotation; anonymer Browser prüft Fragmenttausch und Cookie | erfüllt am 23.10.2026 |
| Private Quellen und Uploads bleiben intern | HTML- und JSON-Prüfung enthält weder implizite Quellen, Uploadnamen noch interne Redaktionstexte | erfüllt am 23.10.2026 |
| Responsive und gleichwertig bedienbar | Chromium prüft Desktop/Mobil, Light/Dark, Drag-and-drop sowie Klick- und Tastaturalternativen | erfüllt am 23.10.2026 |
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
## Ausgeführte Prüfungen 0.5.8
- `npm run lint`: nach Formatierung erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 33 Tests in 6 Dateien erfolgreich
- `npm run test:e2e`: 15 von 15 Chromium-Browsertests erfolgreich
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, horizontalem Überlauf, Tastaturbedienung, Planung, Live-Steuerung
und anonymer Ausgabe geprüft; Screenshots wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run db:migrate`: Migrationen `0007_editorial_workflows.sql` und
`0008_durable_planning_commands.sql` erfolgreich auf der zuvor gesicherten
lokalen Entwicklungsdatenbank angewendet
- `bash -n start.sh`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen
+38 -7
View File
@@ -41,7 +41,7 @@ und spätere Bereiche.
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte |
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; revisionierte Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
@@ -102,6 +102,13 @@ Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
diese Felder nicht.
Piece-Vorlagen liegen als validierte JSON-Snapshots in `piece_templates`. Jeder
Speichervorgang kopiert den vollständigen Vorlageninhalt tief und erhöht bei
einer Aktualisierung die Revision; spätere Änderungen am Ausgangs-Piece wirken
nicht auf die Vorlage zurück. Die leere Vorlage bleibt virtuell. Interner Titel
und optionaler Präsentationstitel sind getrennte Felder; ohne öffentlichen Titel
verwendet die Präsentationsprojektion den internen Titel als Fallback.
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
@@ -161,6 +168,11 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern.
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
zurückgibt und weder ein zweites Piece noch eine zweite Einplanung erzeugt.
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
@@ -191,12 +203,19 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de
geplanten Dauer nachvollziehbar.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe
`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation
oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein
neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören.
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im
URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie
getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den
rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
erneutes Öffnen behält ihn bei.
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
Positivliste ab. Implizite Piece-Quellen und private Uploads werden nicht
veröffentlicht; nur die ausdrücklich gewählte Link- oder Videoquelle und deren
erlaubte Vorschau-URL können die Grenze passieren.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
@@ -239,6 +258,18 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
+34
View File
@@ -276,3 +276,37 @@ Quellen:
- <https://developers.tiktok.com/doc/embed-player>
- <https://developers.tiktok.com/doc/embed-videos>
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
## D-020: Stabile Ausgabekanal-Adresse mit Fragment-Schlüssel
OBS soll eine dauerhaft konfigurierte Adresse verwenden können, ohne dass ein
Zugangsschlüssel in HTTP-Requests, Serverlogs oder Referer-Headern erscheint.
Darum bleibt die Kanaladresse `/heinzlive/default` stabil und trägt den
zufälligen Schlüssel ausschließlich als URL-Fragment. Ein kleiner Browser-POST
tauscht ihn gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie; danach greifen
Seite, Polling und erlaubte Proxys nur mit diesem Cookie zu. Für die Prüfung
liegt ein SHA-256-Hash vor. Der rohe Schlüssel muss zusätzlich in SQLite
gespeichert werden, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
nach einem Neustart erneut ausgeben können muss. Damit sind Datenbank und
Sicherungen bewusst Teil der Geheimnisgrenze. Wiederholtes Öffnen behält den
Schlüssel, eine ausdrückliche Rotation widerruft ihn sofort.
Die öffentliche Projektion folgt einer engeren Positivliste als der interne
Piece-Snapshot. Weder private Uploads noch alle Piece-Quellen werden implizit
veröffentlicht. Passieren dürfen nur öffentlicher Text, Präsentationstitel sowie
die ausdrücklich gewählte und erneut validierte Link- oder Videoquelle mit
erlaubten Vorschaufeldern.
## D-021: Vorlagen-Snapshots und idempotente Direktanlage
Persistente Piece-Vorlagen speichern validierte, tief kopierte JSON-Snapshots
mit eigener Revision. Damit bleiben sie unabhängig von späteren Änderungen am
Ausgangs-Piece, ohne für jedes optionale Piece-Feld eine zweite Tabellenstruktur
zu benötigen. Die virtuelle leere Vorlage wird nicht persistiert.
Eine Direktanlage im Sendungsplan ist ein einzelner fachlicher Befehl. Piece,
Verwendung und Sendungsrevision werden in derselben SQLite-Transaktion
geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
Einplanen getrennt in den Verwendungssnapshot kopiert.
+126
View File
@@ -7,6 +7,110 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen.
## Redaktionelle Arbeitsabläufe 0.5.8
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
Vorlagen lassen sich aus Fundus-Pieces speichern, erneut verwenden und löschen
- atomare, idempotente Piece-Neuanlage direkt in einer Sendung einschließlich
Einplanung; fehlgeschlagene Schritte hinterlassen weder Piece noch Verwendung
- getrennte interne und öffentliche Titel an Piece und Verwendung; ein leerer
Präsentationstitel fällt kontrolliert auf den internen Titel zurück
- präzises Entfernen einzelner Verwendungen mit Bestätigung sowie zusätzlicher
Drag-and-drop-Rückgabe in den Fundusbereich
- Auswahl jedes konkreten Ablauf-Pieces in der Live-Steuerung, einschließlich
erstem und bereits erledigtem Eintrag
- stabiler OBS-Ausgabekanal `/heinzlive/default` mit geheimem URL-Fragment,
HttpOnly-Cookie-Austausch und ausdrücklicher Schlüsselrotation
- öffentliche Ausgabe als Positivliste: implizite Piece-Quellen und private
Uploads werden nicht mehr veröffentlicht; nur ausdrücklich gewählte
Link-/Videoquellen und deren erlaubte Vorschau bleiben sichtbar
Die vollständige lokale Prüfmatrix umfasst 33 Verhaltenstests und 15
Chromium-Abläufe. Desktop, Mobil, Light/Dark, Tastatur- und Touchalternativen,
Planung, Live-Ausgabe sowie anonyme Ausgabedaten wurden geprüft. Reales OBS und
echte YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
## Materialkorrektur 0.5.7
- statische Gold-/Blauviolettlichter so positioniert und verstärkt, dass sie im
eigentlichen Arbeitsbereich statt fast ausschließlich hinter der Navigation
sichtbar werden
- gemeinsame Materialtokens auf Außenglas, dunkle Innenfläche und tiefe
Ablaufkarte reduziert; verschachtelte Abschnitte, Pieces und Aktionsgruppen
werden nach innen dunkler statt heller und blaugrau
- aktive Editortabs transparent goldgetönt und private Recherche sowie
Quellen-/Materialbereiche auf transparente Nachtblauflächen zurückgeführt
- Dialog-Backdrop von starker Abdunklung und Unschärfe auf eine leichtere
Trennung reduziert, sodass die Lichtstimmung bei geöffnetem Editor erhalten
bleibt
- deckende Schreibfelder, Goldfokus, Lightmode, Mobilansicht und lesbare
Komponentenfüllungen ohne verfügbaren Blur bleiben erhalten
Die berechneten Hintergründe, Transparenzen, Filter und Begrenzungseigenschaften
wurden vor und nach der Korrektur in Chromium kontrolliert. Sendungsplanung,
Sendungstext und Recherche wurden bei 1440 × 1000 und 390 × 844 Pixeln in beiden
Themes visuell geprüft.
## Oberflächenkorrektur 0.5.6
- sichtbare statische Gold- und Blauviolettlichter hinter den halbtransparenten
Navigations- und Editorflächen
- klarere Glaswirkung in Navigation sowie Kopf, Tabs und Fuß des Piece-Editors;
Schreibflächen bleiben deckend, ruhig und goldfokussiert
- Fundus mit dauerhaft sichtbarer Suche und Neuanlage, aufklappbaren Zusatzfiltern
und inhaltsorientierten, vollständig anklickbaren Zeilen
- vorhandene Vorschaubilder beziehungsweise kleine Symbolplatzhalter sowie
gebündelte Menüs für Duplizieren und Archivieren
- bestehende Server-Actions, Filterlogik und Editor-Bereiche unverändert
Geprüft wurden `npm run check` sowie Fundus und geöffneter Piece-Editor in
Chromium bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark. Die gezielte
Kontrolle umfasste Tastaturöffnung, Menüs, Fokus, Glaswerte, Seitenüberlauf und
visuell geprüfte Screenshots. Produktionsbuild und vollständige Testmatrix wurden
gemäß Auftrag nicht ausgeführt.
## Oberflächenkorrektur 0.5.5
- dunkle Grundfläche mit sehr zurückhaltenden Gold-/Cyan-Lichtfeldern innerhalb
der Anwendung statt gleichmäßig deckender Blauflächen
- Navigation, große Arbeitsflächen und Piece-Dialog als dunkles beziehungsweise
helles Glas mit feiner Lichtkante, Unschärfe und ruhigem Schatten
- Gold für Dialogkontur, Fokus, aktive Zustände und Hauptaktionen; Cyan nur noch
für ergänzende redaktionelle Hinweise und Links
- klar abgegrenzte, weitgehend deckende Schreibfelder mit Goldfokus, Platzhaltern
und nach Textrolle abgestuften Höhen
- bestehende nachträgliche CSS-Override-Schicht entfernt und die Gestaltung in
gemeinsame Tokens und ursprüngliche Komponentenregeln zurückgeführt
Geprüft wurden Typen, Produktionsbuild sowie gezielte Chromium-Ansichten von
Eingang, Fundus, Piece-Editor, Sendungsplanung und Live-Steuerung bei 1440 × 1000
und 390 × 844 Pixeln in beiden Themes. Die vollständige Unit- und E2E-Matrix
bleibt wie beauftragt Schritt 3 vorbehalten.
## Oberflächenrelease 0.5.4
- gemeinsame Light-/Dark-Tokens für ruhige Arbeitsflächen, Cyan als
Interaktionsfarbe und Gold für die jeweilige Hauptaktion
- Darkmode im heinzlive-Farbraum mit Hintergrund `#0B1220`, Flächen `#152238`,
Text `#FFF7E8`, Sekundärtext `#A9B6C8`, Gold `#E7B957`, Cyan `#67DDE8` und
Grenzen `#30435C`
- vereinheitlichte Navigation, Formulare, Meldungen, Listen, Sendungsplan,
Live-Steuerung und Piece-Dialoge ohne Änderung der Bedienlogik
- kompaktere Planzeilen mit sichtbarem Drag-Griff und waagerechter Aktionsgruppe;
Klick-, Touch- und Tastaturalternativen bleiben erhalten
- verständliche Kontextnamen „Fundusfassung“ und „Sendungsfassung“ sowie weniger
technische Erläuterungen im redaktionellen Arbeitsfluss
- dezente Speicheraktion im gespeicherten Zustand und hervorgehobene Aktion bei
ungespeicherten Änderungen, Fehlern oder Konflikten
Für diesen Design-Release wurden `npm run check`, `npm run build` und gezielte
Chromium-Sichtkontrollen auf Eingang, Fundus, Piece-Editor, Sendungsplan und
Live-Steuerung bei 1440 × 1000 sowie 390 × 844 Pixeln in Light/Dark ausgeführt.
Konsolenfehler und horizontaler Seitenüberlauf traten dabei nicht auf. Die
vollständige Unit- und E2E-Matrix wurde für die rein visuelle Änderung bewusst
nicht erneut ausgeführt; die fachlichen Nachweise der Etappen 1 bis 5 bleiben
unverändert.
## Umgesetzter Stand Etappe 1
- SvelteKit-/TypeScript-Monolith mit Node-Adapter
@@ -71,6 +175,28 @@ als bereits implementiert verstanden werden.
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
Original sowie Verwendung bleiben getrennte Revisionsgrenzen
- Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
- Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
Vorschau stehen gemeinsam im Präsentationsbereich
- der Dialog bleibt innerhalb der Desktop-Fensterhöhe, hält Kopf,
Bereichsnavigation und Aktionen sichtbar und nutzt mobil die volle
Bildschirmfläche; Bereichswechsel hängen Eingaben nicht aus und merken die
jeweilige Scrollposition
- die Schnellaufnahme akzeptiert Gedanke oder URL, bleibt nach dem Ablegen im
Eingang und öffnet die Ausarbeitung erst über eine bewusste Aktion
Für diese gezielte Überarbeitung wurden `npm run check` sowie kurze
Chromium-Sichtkontrollen des Original- und Verwendungseditors bei 1440 × 1000
und 390 × 844 Pixeln ausgeführt. Dabei waren Dialoggrenzen, feste Bedienbereiche,
alle drei Arbeitsbereiche und horizontaler Überlauf unauffällig. Die vollständige
Unit-, E2E- und Build-Matrix ist gemäß Auftrag noch offen.
## Umgesetzter Stand Etappe 4
+58
View File
@@ -0,0 +1,58 @@
ALTER TABLE pieces ADD COLUMN output_title TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN output_title_snapshot TEXT NOT NULL DEFAULT '';
CREATE TABLE piece_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
snapshot_json TEXT NOT NULL CHECK (json_valid(snapshot_json)),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
INSERT INTO piece_templates (
id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc
) VALUES
(
'talk', 'Gespräch',
'Leitfragen, Recherche und eine klare Überleitung.',
'{"title":"Unbenanntes Gespräch","formatId":"format-talk","moderationNotes":"## Leitfragen\n\n- ","categoryIds":["cat-jc"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
),
(
'find', 'Reaction / Fundstück',
'Quelle, Einordnung und präsentierbare Kernaussage.',
'{"title":"Neues Fundstück","formatId":"format-find","presentationText":"## Kernaussage\n\n","categoryIds":["cat-reaction"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
),
(
'interaction', 'Interaktion',
'Quiz, Publikumsfrage oder kleines Spiel.',
'{"title":"Neue Interaktion","formatId":"format-quiz","moderationNotes":"## Ablauf\n\n1. ","categoryIds":["cat-game"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
);
CREATE TABLE planning_commands (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
CREATE TABLE presentation_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
access_key TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
show_id TEXT REFERENCES shows(id) ON DELETE SET NULL,
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
UPDATE presentation_access_tokens
SET revoked_at_utc = COALESCE(revoked_at_utc, '2026-10-23T00:00:00.000Z');
@@ -0,0 +1,18 @@
CREATE TABLE planning_commands_durable (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
piece_id TEXT NOT NULL,
usage_id TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
INSERT INTO planning_commands_durable (
show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
)
SELECT show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
FROM planning_commands;
DROP TABLE planning_commands;
ALTER TABLE planning_commands_durable RENAME TO planning_commands;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
"version": "0.5.1",
"version": "0.5.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.5.1",
"version": "0.5.8",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.5.1",
"version": "0.5.8",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+1788 -150
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,81 @@
<script lang="ts">
import { tick } from 'svelte';
let {
name,
label,
value = $bindable(),
rows = 4,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let textarea: HTMLTextAreaElement;
async function replaceSelection(before: string, after = before, fallback = 'Text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || fallback;
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
async function addList() {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || 'Stichpunkt';
const replacement = selected
.split('\n')
.map((line) => `- ${line}`)
.join('\n');
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + 2, start + replacement.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
</script>
<div class:private-field={privateField} class="markdown-editor-field">
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
><strong>B</strong></button
>
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button
>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button>
<button
type="button"
aria-label="Link"
title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button
>
</span>
</div>
<textarea
bind:this={textarea}
aria-label={label}
{name}
{rows}
{placeholder}
{form}
maxlength="50000"
bind:value></textarea>
</div>
@@ -0,0 +1,81 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
let {
labelledby,
activeSection = $bindable(),
onrequestclose,
header,
children,
footer
}: {
labelledby: string;
activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void;
header: Snippet;
children: Snippet;
footer: Snippet;
} = $props();
let dialog: HTMLDialogElement;
let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 };
async function selectSection(section: typeof activeSection) {
scrollPositions[activeSection] = workspace.scrollTop;
activeSection = section;
await tick();
workspace.scrollTop = scrollPositions[section];
}
export function showModal() {
if (!dialog.open) dialog.showModal();
}
export function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
<header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div>
<button
class="dialog-close"
type="button"
aria-label="Editor schließen"
onclick={onrequestclose}>×</button
>
</header>
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button
type="button"
class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button
>
<button
type="button"
class:active={activeSection === 'research'}
aria-current={activeSection === 'research' ? 'page' : undefined}
onclick={() => selectSection('research')}>Recherche & Quellen</button
>
<button
type="button"
class:active={activeSection === 'presentation'}
aria-current={activeSection === 'presentation' ? 'page' : undefined}
onclick={() => selectSection('presentation')}>Präsentation</button
>
</nav>
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer>
</dialog>
+232 -114
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
@@ -19,12 +21,30 @@
error?: string;
enhanceCommand: SubmitFunction;
} = $props();
let dialog: HTMLDialogElement;
let presentationMode = $state(untrack(() => usage.presentationMode));
let formId = $derived(`usage-editor-form-${usage.id}`);
let editor: { showModal: () => void; close: () => void };
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let draft = $state(untrack(() => ({ ...usage })));
let dirty = $state(false);
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false;
editor.close();
}
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
if (finish) await finish(result);
if (result.result.type === 'success') dirty = false;
};
};
</script>
<button
@@ -32,45 +52,77 @@
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()}
onclick={() => editor.showModal()}
>
<NavIcon name="edit" />
</button>
<dialog
bind:this={dialog}
class="usage-editor-dialog"
aria-labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()}
<PieceEditorDialog
bind:this={editor}
bind:activeSection
labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose}
>
<header>
<div>
<p class="eyebrow">Sendungsspezifische Kopie</p>
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
</div>
<form method="dialog">
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
</form>
</header>
<div class="usage-dialog-scroll">
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
{#snippet header()}
<p class="eyebrow">Sendungsfassung</p>
<label class="piece-dialog-title">
<span class="sr-only">Titel</span>
<textarea
form={formId}
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}
oninput={() => (dirty = true)}></textarea>
</label>
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet}
<form
use:enhance={enhanceCommand}
id={formId}
use:enhance={enhanceEditor}
method="POST"
action="?/updateUsage"
class="usage-dialog-editor"
class="piece-dialog-form"
oninput={() => (dirty = true)}
onchange={() => (dirty = true)}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="editor-section">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h3>Titel, Dauer und Notizen</h3>
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" value={usage.title} /></label
>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
/>
<MarkdownEditorField
name="moderation"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
name="transition"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transition}
/>
</div>
<details class="piece-meta-details">
<summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid">
<label
>Dauer in Minuten<input
name="durationMinutes"
@@ -81,83 +133,111 @@
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div>
</details>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h3>Getrennte Arbeitsbereiche</h3>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h3>Recherche & Quellen</h3>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
>{usage.intro}</textarea
></label
>
<label class="private-field"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
>{usage.research}</textarea
></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderation"
rows="7"
maxlength="50000">{usage.moderation}</textarea
></label
>
<label
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
>{usage.transition}</textarea
></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
>{usage.presentation}</textarea
></label
>
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div>
<div class="presentation-settings">
<div class="piece-writing-stack">
<MarkdownEditorField
name="research"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.research}
/>
<MarkdownEditorField
name="ownNotes"
label="Interne Notizen zu dieser Verwendung"
placeholder="Absprachen und Hinweise nur für diese Sendung"
privateField
bind:value={draft.ownNotes}
/>
</div>
<section class="piece-source-summary">
<p class="eyebrow">Snapshot-Quelle</p>
{#if draft.presentationSourceUrl}
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
<small>{draft.presentationSourceUrl}</small>
{:else}
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
{/if}
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
<h3>Präsentation</h3>
</div>
<small>Private Recherche und Notizen werden nicht ausgegeben.</small>
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
<option value="text">Text</option>
<option value="link">Linkvorschau</option>
<option value="video">Video</option>
</select></label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
{#if presentationMode !== 'text'}
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
<input
type="hidden"
name="presentationSourceLabel"
value={usage.presentationSourceLabel}
value={draft.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={usage.presentationSourceTimecodeSeconds ?? ''}
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
{#if draft.presentationMode !== 'text'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{usage.presentationSourceLabel ||
usage.presentationSourceUrl ||
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
>Kartentitel<input
name="presentationTitle"
maxlength="300"
value={usage.presentationTitle}
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
<label
@@ -165,64 +245,102 @@
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
placeholder="https://…"
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}></textarea></label
>
</div>
{:else}
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{:else}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{/if}
</aside>
</div>
</section>
<button class="primary-button" type="submit">Verwendung speichern</button>
</form>
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
<div>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>
</form>
<small
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit">Duplizieren</button>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button" type="submit" disabled={dirty}>Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="position" value="9999" />
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" />
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each sections as target (target.id)}
<option value={target.id} selected={target.id === sectionId}>{target.name}</option>
{/each}
</select></label
><span class="sr-only">Ziel</span><select name="sectionId"
><option value="">Reservebank</option>{#each sections as target (target.id)}<option
value={target.id}
selected={target.id === sectionId}>{target.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Verschieben</button>
<button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button danger-text" type="submit">Entfernen</button>
</form>
</section>
</div>
</dialog>
<div class="piece-dialog-primary-action">
{#if error}<span class="problem" role="alert">{error}</span>{/if}
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
</div>
{/snippet}
</PieceEditorDialog>
+42
View File
@@ -12,9 +12,11 @@ import {
getMediaFile,
getPiece,
getTaxonomy,
listPieceTemplates,
listPieces,
loadPiecePresentationMetadata,
savePiece,
savePieceAsTemplate,
setPieceArchived,
uploadPieceImage,
type PieceInput
@@ -42,6 +44,7 @@ afterEach(() => {
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return {
title: 'Getestetes Piece',
outputTitle: '',
summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk',
status: 'ready',
@@ -73,6 +76,45 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
}
describe('content management', () => {
it('persists independently versioned templates and copies their complete snapshot', () => {
const sourceId = createPiece('talk');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext'
})
);
const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand');
const template = listPieceTemplates().find((entry) => entry.id === created.id)!;
expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 });
const changed = getPiece(sourceId)!;
savePiece(sourceId, changed.revision, {
...changed,
status: 'ready',
presentationText: 'Spätere Änderung'
});
const copyId = createPiece(created.id);
expect(getPiece(copyId)).toMatchObject({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext',
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]
});
const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1);
expect(updated.revision).toBe(2);
expect(() =>
savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1)
).toThrow('CONFLICT');
});
it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk');
const initial = getPiece(id)!;
+129 -72
View File
@@ -23,6 +23,7 @@ export type PieceSourceInput = {
export type PieceInput = {
title: string;
outputTitle: string;
summary: string;
formatId: string | null;
status: EditablePieceStatus;
@@ -88,30 +89,12 @@ export type Taxonomy = {
topics: { id: string; name: string }[];
};
export const pieceTemplates = [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.'
},
{
id: 'talk',
name: 'Gespräch',
description: 'Leitfragen, Recherche und eine klare Überleitung.'
},
{
id: 'find',
name: 'Reaction / Fundstück',
description: 'Quelle, Einordnung und präsentierbare Kernaussage.'
},
{
id: 'interaction',
name: 'Interaktion',
description: 'Quiz, Publikumsfrage oder kleines Spiel.'
}
] as const;
export type PieceTemplateId = (typeof pieceTemplates)[number]['id'];
export type PieceTemplate = {
id: string;
name: string;
description: string;
revision: number;
};
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([
@@ -202,6 +185,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return {
title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
@@ -251,6 +235,7 @@ export function getTaxonomy(): Taxonomy {
type PieceListRow = {
id: string;
title: string;
output_title: string;
summary: string;
status: PieceStatus;
revision: number;
@@ -341,6 +326,7 @@ export function listPieces(filters: {
type PieceRow = {
id: string;
title: string;
output_title: string;
summary: string;
format_id: string | null;
status: PieceStatus;
@@ -371,7 +357,7 @@ export function getPiece(id: string): PieceEditor | null {
const database = getDatabase();
const row = database
.prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
@@ -385,6 +371,7 @@ export function getPiece(id: string): PieceEditor | null {
return {
id: row.id,
title: row.title,
outputTitle: row.output_title,
summary: row.summary,
formatId: row.format_id,
status: row.status,
@@ -484,7 +471,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => {
const result = database
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
@@ -496,6 +483,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
)
.run(
input.title,
input.outputTitle,
input.summary,
input.formatId,
input.status,
@@ -531,50 +519,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
return { revision: expectedRevision + 1, updatedAtUtc };
}
function templateDefaults(template: PieceTemplateId): Partial<PieceInput> & { title: string } {
if (template === 'talk') {
function blankPieceInput(): PieceInput {
return {
title: 'Unbenanntes Gespräch',
formatId: 'format-talk',
categoryIds: ['cat-jc'],
moderationNotes: '## Leitfragen\n\n- '
};
}
if (template === 'find') {
return {
title: 'Neues Fundstück',
formatId: 'format-find',
categoryIds: ['cat-reaction'],
presentationText: '## Kernaussage\n\n'
};
}
if (template === 'interaction') {
return {
title: 'Neue Interaktion',
formatId: 'format-quiz',
categoryIds: ['cat-game'],
moderationNotes: '## Ablauf\n\n1. '
};
}
return { title: 'Neues Piece' };
}
export function createPiece(template: PieceTemplateId = 'blank'): string {
if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE');
const defaults = templateDefaults(template);
const id = randomUUID();
const now = new Date().toISOString();
const input = validatePieceInput({
title: defaults.title,
title: 'Neues Piece',
outputTitle: '',
summary: '',
formatId: defaults.formatId ?? null,
formatId: null,
status: 'preparing',
durationSeconds: 0,
intro: '',
researchText: '',
moderationNotes: defaults.moderationNotes ?? '',
moderationNotes: '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
presentationText: '',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
@@ -588,20 +545,63 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
categoryIds: [],
sources: []
});
};
}
function templateInput(templateId: string): PieceInput {
if (templateId === 'blank') return blankPieceInput();
const row = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
.get(templateId) as { snapshot_json: string } | undefined;
if (!row) throw new Error('INVALID_TEMPLATE');
try {
const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch {
throw new Error('INVALID_TEMPLATE');
}
}
export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase()
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[];
return [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.',
revision: 0
},
...rows.filter((template) => {
try {
templateInput(template.id);
return true;
} catch {
return false;
}
})
];
}
export function createPiece(template = 'blank'): string {
const input = templateInput(template);
const id = randomUUID();
const now = new Date().toISOString();
const database = getDatabase();
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
input.title,
input.outputTitle,
input.summary,
input.formatId,
input.status,
@@ -619,12 +619,68 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
return id;
}
export function savePieceAsTemplate(
pieceId: string,
nameValue: string,
descriptionValue: string,
templateId?: string,
expectedRevision?: number
) {
const piece = getPiece(pieceId);
if (!piece) throw new Error('NOT_FOUND');
const name = cleanText(nameValue, 120, 'template_name');
if (!name) throw new Error('INVALID_TEMPLATE_NAME');
const description = cleanText(descriptionValue, 500, 'template_description');
const snapshot: Partial<typeof piece> = structuredClone(piece);
delete snapshot.id;
delete snapshot.revision;
delete snapshot.createdAtUtc;
delete snapshot.updatedAtUtc;
delete snapshot.media;
snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase();
const now = new Date().toISOString();
if (!templateId) {
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_templates (id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 1, ?, ?)`
)
.run(id, name, description, snapshotJson, now, now);
return { id, revision: 1 };
}
if (!Number.isInteger(expectedRevision) || Number(expectedRevision) < 1) {
throw new Error('INVALID_REVISION');
}
const result = database
.prepare(
`UPDATE piece_templates SET name = ?, description = ?, snapshot_json = ?,
revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?`
)
.run(name, description, snapshotJson, now, templateId, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM piece_templates WHERE id = ?').get(templateId);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
return { id: templateId, revision: Number(expectedRevision) + 1 };
}
export function deletePieceTemplate(templateId: string, expectedRevision: number) {
const result = getDatabase()
.prepare('DELETE FROM piece_templates WHERE id = ? AND revision = ?')
.run(templateId, expectedRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
}
export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
let title = cleanText(titleValue, 200, 'title');
const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
const id = createPiece('blank');
if (!title && !url) throw new Error('INVALID_TITLE');
if (!title) title = new URL(url).hostname;
const id = createPiece();
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
@@ -691,18 +747,19 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
`Kopie von ${source.title}`.slice(0, 200),
source.outputTitle,
source.summary,
source.formatId,
source.durationSeconds,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 6,
migrations: 8,
pieces: 6,
shows: 2,
usages: 5,
+63 -3
View File
@@ -6,13 +6,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
clearPresentationChannel,
getPresentationChannelView,
getLiveView,
getPresentationMedia,
getPresentationView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentationChannelAccess,
presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent,
startOrResumeLive
} from './live.js';
@@ -35,6 +40,57 @@ afterEach(() => {
});
describe('live mode', () => {
it('selects any rundown usage by stable id, including the first and completed entries', () => {
let live = getLiveView('show-october');
expect(() =>
selectUsage('show-october', 'usage-effects', 'select-before-start', live.liveRevision)
).toThrow('NOT_STARTED');
startOrResumeLive('show-october', 'start-selection', live.liveRevision);
live = getLiveView('show-october');
selectUsage('show-october', 'usage-effects', 'select-second', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-effects');
selectUsage('show-october', 'usage-console', 'select-first', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-console');
completeCurrent('show-october', 'complete-first', live.liveRevision);
live = getLiveView('show-october');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')?.status
).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console',
status: 'current'
});
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({
revision: 1_000_000_000,
content: null
});
const replacement = rotatePresentationChannelKey('show-october');
expect(replacement).not.toBe(key);
expect(presentationChannelAccess('default', key)).toBe(false);
expect(presentationChannelAccess('default', replacement)).toBe(true);
startOrResumeLive('show-october', 'start-channel', 0);
presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel();
expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull();
});
it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started);
@@ -115,7 +171,8 @@ describe('live mode', () => {
const output = getPresentationView(token)!;
expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
images: [],
sources: []
});
const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
@@ -125,11 +182,14 @@ describe('live mode', () => {
expect(serialized).not.toContain('presentation.png');
const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image');
expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toBeNull();
expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
+128 -50
View File
@@ -1,6 +1,5 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -414,6 +413,34 @@ export function goBack(
);
}
export function selectUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'select',
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
@@ -480,25 +507,90 @@ export function issuePresentationAccess(showId: string) {
const database = getDatabase();
const now = nowIso();
ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url');
database.transaction(() => {
return database.transaction(() => {
let channel = database
.prepare("SELECT * FROM presentation_channels WHERE id = 'default'")
.get() as { access_key: string; show_id: string | null } | undefined;
if (!channel) {
const accessKey = randomBytes(32).toString('base64url');
clearPresentationState(database, showId);
database
.prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
`INSERT INTO presentation_channels
(id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc)
VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)`
)
.run(now, showId);
.run(accessKey, hashToken(accessKey), showId, now, now);
channel = { access_key: accessKey, show_id: showId };
} else if (channel.show_id !== showId) {
clearPresentationState(database, showId);
database
.prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
VALUES (?, ?, ?, ?)`
`UPDATE presentation_channels SET show_id = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(randomUUID(), showId, hashToken(token), now);
.run(showId, now);
}
return channel.access_key;
})();
return token;
}
function clearPresentationState(database: Database.Database, showId: string) {
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = NULL,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ? AND presentation_usage_id IS NOT NULL`
)
.run(nowIso(), showId);
}
export function clearPresentationChannel() {
const now = nowIso();
getDatabase().transaction(() => {
const database = getDatabase();
const channel = database
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
.get() as { show_id: string | null } | undefined;
if (!channel) throw new Error('NOT_FOUND');
if (channel.show_id) clearPresentationState(database, channel.show_id);
database
.prepare(
`UPDATE presentation_channels SET show_id = NULL, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(now);
})();
}
export function rotatePresentationChannelKey(showId: string) {
issuePresentationAccess(showId);
const accessKey = randomBytes(32).toString('base64url');
const now = nowIso();
getDatabase()
.prepare(
`UPDATE presentation_channels SET access_key = ?, key_hash = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(accessKey, hashToken(accessKey), now);
return accessKey;
}
export function presentationChannelAccess(channelId: string, accessKey: string) {
if (!channelId || !accessKey || accessKey.length > 200) return false;
return Boolean(
getDatabase()
.prepare('SELECT 1 FROM presentation_channels WHERE id = ? AND key_hash = ?')
.get(channelId, hashToken(accessKey))
);
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const channel = database
.prepare('SELECT show_id FROM presentation_channels WHERE key_hash = ?')
.get(hashToken(token)) as { show_id: string | null } | undefined;
if (channel) return channel.show_id;
const row = database
.prepare(
`SELECT show_id FROM presentation_access_tokens
@@ -508,15 +600,30 @@ function presentationShowId(database: Database.Database, token: string) {
return row?.show_id ?? null;
}
export function getPresentationChannelView(channelId: string): PresentationView | null {
const channel = getDatabase()
.prepare('SELECT show_id, revision FROM presentation_channels WHERE id = ?')
.get(channelId) as { show_id: string | null; revision: number } | undefined;
if (!channel) return null;
if (!channel.show_id) return { revision: channel.revision * 1_000_000_000, content: null };
const view = getPresentationViewForShow(channel.show_id);
return { ...view, revision: channel.revision * 1_000_000_000 + view.revision };
}
export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
return getPresentationViewForShow(showId);
}
function getPresentationViewForShow(showId: string): PresentationView {
const database = getDatabase();
const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id,
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -528,6 +635,7 @@ export function getPresentationView(token: string): PresentationView | null {
.get(state.presentation_usage_id, showId) as
| {
title_snapshot: string;
output_title_snapshot: string;
presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode'];
@@ -543,33 +651,11 @@ export function getPresentationView(token: string): PresentationView | null {
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
title: usage.title_snapshot,
title: usage.output_title_snapshot || usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot
? {
@@ -589,8 +675,8 @@ export function getPresentationView(token: string): PresentationView | null {
usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot
),
sources,
images
sources: [],
images: []
}
};
}
@@ -605,19 +691,11 @@ export async function getPresentationPreviewImage(token: string) {
}
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const allowed = database
.prepare(
`SELECT 1 FROM show_live_states l
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
export async function getPresentationMedia(
token: string,
mediaId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
void token;
void mediaId;
return null;
}
+63 -1
View File
@@ -9,6 +9,7 @@ import {
addPieceUsage,
addShowSection,
createShow,
createAndAddPieceUsage,
duplicatePieceUsage,
getPieceUsageContexts,
getShow,
@@ -44,6 +45,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
const edited = { ...usage, ...overrides };
return {
title: edited.title,
outputTitle: edited.outputTitle,
intro: edited.intro,
research: edited.research,
moderation: edited.moderation,
@@ -63,6 +65,64 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
}
describe('show builder data layer', () => {
it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const before = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Direkt geplantes Piece',
templateId: 'talk',
sectionId: section.id
});
const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Darf nicht doppelt entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(repeated).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(1);
expect(getPiece(first.pieceId)).toMatchObject({
title: 'Direkt geplantes Piece',
status: 'ready',
formatId: 'format-talk'
});
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
expect(() =>
createAndAddPieceUsage(created.id, first.revision, 'failing-command', {
title: 'Rollback',
templateId: 'blank',
sectionId: 'missing'
})
).toThrow('NOT_FOUND');
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
const removed = removePieceUsage(created.id, first.usageId, first.revision);
const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', {
title: 'Darf auch nach dem Entfernen nicht neu entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(afterRemoval).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(0);
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
});
it('creates blank and templated shows with independent sections and reserve banks', () => {
const templatesBefore = listShowTemplates();
expect(
@@ -360,7 +420,8 @@ describe('show builder data layer', () => {
.run('https://youtu.be/dQw4w9WgXcQ');
getDatabase()
.prepare(
`UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0,
`UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel',
presentation_mode = 'video', presentation_source_position = 0,
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
@@ -378,6 +439,7 @@ describe('show builder data layer', () => {
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
outputTitle: 'Öffentlicher Ausgabetitel',
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75,
+76 -10
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { createPiece } from './content.js';
import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
@@ -42,6 +43,7 @@ export type PieceUsage = {
status: UsageStatus;
durationSeconds: number;
title: string;
outputTitle: string;
intro: string;
research: string;
moderation: string;
@@ -129,6 +131,7 @@ export type PieceUsageInput = {
export type PieceUsageUpdate = {
title: string;
outputTitle: string;
intro: string;
research: string;
moderation: string;
@@ -185,6 +188,7 @@ type UsageRow = {
status: UsageStatus;
duration_seconds: number;
title_snapshot: string;
output_title_snapshot: string;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
@@ -376,6 +380,7 @@ function usageFromRow(row: UsageRow): PieceUsage {
status: row.status,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
outputTitle: row.output_title_snapshot,
intro: row.intro_snapshot,
research: row.research_snapshot,
moderation: row.moderation_snapshot,
@@ -478,7 +483,7 @@ export function getShow(id: string): Show | null {
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
@@ -805,7 +810,7 @@ export function addPieceUsage(
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
@@ -818,6 +823,7 @@ export function addPieceUsage(
.get(input.pieceId) as
| {
title: string;
output_title: string;
duration_seconds: number;
intro: string;
research_text: string;
@@ -849,14 +855,14 @@ export function addPieceUsage(
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -865,6 +871,7 @@ export function addPieceUsage(
ids.length,
piece.duration_seconds,
piece.title,
piece.output_title,
piece.intro,
piece.research_text,
piece.moderation_notes,
@@ -888,6 +895,60 @@ export function addPieceUsage(
})();
}
export function createAndAddPieceUsage(
showId: string,
expectedRevision: number,
commandId: string,
input: Omit<PieceUsageInput, 'pieceId'> & { title: string; templateId: string }
) {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
const title = cleanText(input.title, 200, 'title', true);
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare(
`SELECT piece_id, usage_id, result_revision FROM planning_commands
WHERE show_id = ? AND command_id = ?`
)
.get(showId, commandId) as
{ piece_id: string; usage_id: string; result_revision: number } | undefined;
if (duplicate) {
return {
pieceId: duplicate.piece_id,
usageId: duplicate.usage_id,
revision: duplicate.result_revision,
duplicate: true
};
}
const pieceId = createPiece(input.templateId || 'blank');
database
.prepare(
`UPDATE pieces SET title = ?, status = 'ready', revision = revision + 1,
updated_at_utc = ? WHERE id = ?`
)
.run(title, new Date().toISOString(), pieceId);
const usage = addPieceUsage(showId, expectedRevision, {
pieceId,
sectionId: input.sectionId,
position: input.position
});
database
.prepare(
`INSERT INTO planning_commands
(show_id, command_id, piece_id, usage_id, result_revision, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(showId, commandId, pieceId, usage.id, usage.revision, new Date().toISOString());
return {
pieceId,
usageId: usage.id,
revision: usage.revision,
duplicate: false
};
})();
}
export function duplicatePieceUsage(
showId: string,
usageId: string,
@@ -899,7 +960,7 @@ export function duplicatePieceUsage(
const sourceSection = usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot,
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
@@ -922,14 +983,14 @@ export function duplicatePieceUsage(
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -938,6 +999,7 @@ export function duplicatePieceUsage(
ids.length,
source.duration_seconds,
source.title_snapshot,
source.output_title_snapshot,
source.intro_snapshot,
source.research_snapshot,
source.moderation_snapshot,
@@ -973,7 +1035,7 @@ export function refreshPieceUsageFromOriginal(
usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text,
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
@@ -988,6 +1050,7 @@ export function refreshPieceUsageFromOriginal(
.get(usageId) as
| {
title: string;
output_title: string;
duration_seconds: number;
intro: string;
research_text: string;
@@ -1009,7 +1072,7 @@ export function refreshPieceUsageFromOriginal(
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
@@ -1020,6 +1083,7 @@ export function refreshPieceUsageFromOriginal(
)
.run(
source.title,
source.output_title,
source.duration_seconds,
source.intro,
source.research_text,
@@ -1049,6 +1113,7 @@ export function updatePieceUsage(
input: PieceUsageUpdate
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!;
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
@@ -1074,7 +1139,7 @@ export function updatePieceUsage(
usageSection(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
@@ -1085,6 +1150,7 @@ export function updatePieceUsage(
)
.run(
title,
outputTitle,
values.intro,
values.research,
values.moderation,
+2 -2
View File
@@ -1,4 +1,4 @@
import { fail, redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } from '#lib/server/content.js';
@@ -14,7 +14,7 @@ export const actions = {
const data = await request.formData();
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
return { captured: true, capturedId: id };
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
+6 -3
View File
@@ -17,11 +17,14 @@
<h2>Was soll nicht verloren gehen?</h2>
</div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.captured}<div class="form-message success" role="status">
Im Eingang gespeichert.
<a href={resolve('/fundus/[id]', { id: form.capturedId })}>Jetzt ausarbeiten</a>
</div>{/if}
<form method="POST" action="?/capture" class="capture-form">
<label
>Titel oder Gedanke<input
>Gedanke <span>optional bei URL</span><input
name="title"
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Kurze Idee festhalten"
@@ -36,7 +39,7 @@
placeholder="https://…"
/></label
>
<button class="primary-button" type="submit">Aufnehmen und öffnen</button>
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
{#if data.pieces.length}
+15 -5
View File
@@ -3,12 +3,12 @@ import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
createPiece,
deletePieceTemplate,
duplicatePiece,
getTaxonomy,
listPieces,
pieceTemplates,
setPieceArchived,
type PieceTemplateId
listPieceTemplates,
setPieceArchived
} from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js';
@@ -39,7 +39,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
templates: pieceTemplates,
templates: listPieceTemplates(),
filters
};
};
@@ -57,12 +57,22 @@ export const actions = {
const data = await request.formData();
let id: string;
try {
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId);
id = createPiece(String(data.get('template') || 'blank'));
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
deletePieceTemplate(String(data.get('templateId') || ''), Number(data.get('revision')));
return { intent: 'deleteTemplate', success: 'Vorlage gelöscht.' };
} catch (cause) {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
}
},
duplicate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+55 -17
View File
@@ -59,15 +59,13 @@
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<details class="template-picker">
<summary>Neues Piece anlegen</summary>
<summary><span aria-hidden="true">+</span> Neues Piece</summary>
<div class="template-popover">
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
@@ -78,7 +76,30 @@
</form>
{/each}
</div>
{#if data.templates.some((template) => template.id !== 'blank')}<details
class="template-management"
>
<summary>Vorlagen verwalten</summary>
{#each data.templates.filter((template) => template.id !== 'blank') as template (template.id)}
<form
method="POST"
action="?/deleteTemplate"
onsubmit={(event) =>
!confirm(`Vorlage „${template.name}“ löschen?`) && event.preventDefault()}
>
<input type="hidden" name="templateId" value={template.id} />
<input type="hidden" name="revision" value={template.revision} />
<span>{template.name}</span>
<button class="text-button danger-text" type="submit">Löschen</button>
</form>
{/each}
</details>{/if}
</div>
</details>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<form
id="library-filters"
@@ -86,6 +107,7 @@
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<div class="fundus-search-row">
<label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
name="q"
@@ -94,12 +116,17 @@
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker">
<summary><span aria-hidden="true">≡</span> Weitere Filter</summary>
<div class="filter-panel">
<label
><span>Status</span><select name="status"
><option value="">Alle</option><option
value="ready"
selected={data.filters.status === 'ready'}>Sendefertig</option
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option
><option value="preparing" selected={data.filters.status === 'preparing'}
>In Arbeit</option
></select
></label
>
@@ -121,22 +148,19 @@
>
<label
><span>Abschnitt</span><select name="category"
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option
><option value="">Alle</option
>{#each data.taxonomy.categories as item (item.id)}<option
value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</form>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="library-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
@@ -147,13 +171,20 @@
type="checkbox"
name="includePlanned"
value="1"
form="library-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
<div class="filter-actions">
<button class="secondary-button" type="submit">Filter anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</div>
</div>
</details>
</div>
</form>
{#if visiblePieces.length}
<div class="piece-grid">
@@ -166,9 +197,12 @@
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"
>S</span
>{/if}
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4.5 6.5h15v11h-15zM7.5 14l3-3 2.2 2.2 1.8-1.7 2.5 2.5" />
<circle cx="15.8" cy="9.2" r="1.2" />
</svg>
</span>{/if}
<div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
@@ -193,8 +227,11 @@
></span
>
</div>
<div class="piece-actions">
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
<details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}
><span aria-hidden="true">•••</span></summary
>
<div class="piece-actions-popover">
<form method="POST" action="?/duplicate">
<input type="hidden" name="pieceId" value={piece.id} /><button
class="text-button"
@@ -209,6 +246,7 @@
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
</div>
</details>
</article>
{/each}
</div>
+28 -1
View File
@@ -6,8 +6,10 @@ import {
duplicatePiece,
getPiece,
getTaxonomy,
listPieceTemplates,
loadPiecePresentationMetadata,
savePiece,
savePieceAsTemplate,
setPieceArchived,
uploadPieceImage,
type EditablePieceStatus,
@@ -57,6 +59,7 @@ function pieceInput(data: FormData): PieceInput {
}
return {
title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus,
@@ -120,7 +123,12 @@ export const load: PageServerLoad = ({ locals, params }) => {
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv');
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) };
return {
piece,
taxonomy: getTaxonomy(),
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
rendered: renderedFields(piece)
};
};
export const actions = {
@@ -161,6 +169,25 @@ export const actions = {
}
redirect(303, `/fundus/${params.id}`);
},
saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const reference = text(data, 'templateRef');
const [templateId, revision] = reference.split('|');
try {
savePieceAsTemplate(
params.id,
text(data, 'templateName'),
text(data, 'templateDescription'),
templateId || undefined,
revision ? Number(revision) : undefined
);
return { intent: 'saveTemplate', success: 'Vorlage gespeichert.' };
} catch (cause) {
const problem = saveError(cause);
return fail(problem.status, { intent: 'saveTemplate', ...problem });
}
},
upload: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
+317 -192
View File
@@ -2,8 +2,11 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
@@ -12,8 +15,11 @@
let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let rendered = $state(untrack(() => ({ ...data.rendered })));
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let sourceImageUnavailable = $state(false);
let editorDialog: { showModal: () => void; close: () => void };
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
@@ -26,9 +32,50 @@
saveState === 'error' ||
saveState === 'conflict'
);
let featuredSource = $derived(
draft.presentationSourcePosition === null
? (sources[0] ?? null)
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
);
let sourcePreviewImage = $derived(
draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: ''
);
let previewContent = $derived<PresentationContent>({
mode: draft.presentationMode,
title: draft.outputTitle || draft.title,
html: rendered.presentation,
source: featuredSource
? {
label: featuredSource.label || featuredSource.url,
url: featuredSource.url,
timecodeSeconds: featuredSource.timecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationImageUrl,
siteName: draft.presentationSiteName,
provider: draft.presentationProvider as PresentationContent['provider'],
providerId: draft.presentationProviderId,
embedUrl:
draft.presentationProvider === 'youtube' && draft.presentationProviderId
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: ''
});
let pendingHref: string | null = null;
let allowNavigation = false;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
if (allowNavigation) return;
@@ -66,6 +113,23 @@
editorForm.requestSubmit(saveButton);
}
function requestClose() {
const target = resolve('/fundus');
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
allowNavigation = true;
void goto(target);
return;
}
if (saveState === 'dirty' || saveState === 'saving') {
pendingHref = new URL(target, window.location.href).href;
if (!saving) submitSave();
return;
}
allowNavigation = true;
void goto(target);
}
function addSource() {
sources.push({ label: '', url: '', timecodeSeconds: null });
scheduleSave();
@@ -87,6 +151,7 @@
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
@@ -133,56 +198,88 @@
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<div class="page-wrap editor-page">
<header class="editor-header">
<div>
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
<div class="piece-editor-route">
{#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error}
</div>{/if}
<PieceEditorDialog
bind:this={editorDialog}
bind:activeSection
labelledby="piece-editor-title"
onrequestclose={requestClose}
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#snippet header()}
<p class="eyebrow">Fundusfassung</p>
<label class="piece-dialog-title">
<span class="sr-only">Titel</span>
<textarea
form="piece-editor-form"
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}
oninput={scheduleSave}></textarea>
</label>
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
{/snippet}
<form
bind:this={editorForm}
id="piece-editor-form"
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
class="piece-dialog-form"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<section class="editor-section editor-basics">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h2>Worum geht es?</h2>
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
/>
<MarkdownEditorField
name="moderationNotes"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderationNotes}
/>
<MarkdownEditorField
name="transitionText"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText}
/>
</div>
<details class="piece-meta-details">
<summary>
<span>Status, Dauer und Einordnung</span>
<small
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
>
</summary>
<div class="piece-meta-grid">
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
><option value="inbox">Eingang</option><option value="preparing"
>In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
@@ -193,21 +290,22 @@
bind:value={durationMinutes}
/></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
rows="3"
maxlength="2000"
placeholder="Worum geht es in wenigen Sätzen?"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
<section class="editor-section taxonomy-editor">
<div class="section-title">
<p class="eyebrow">Einordnung</p>
<h2>Themen und Abschnitte</h2>
</div>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
@@ -234,112 +332,129 @@
>{/each}
</div>
</fieldset>
</div>
</details>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h2>Getrennte Arbeitsbereiche</h2>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
</div>
</section>
<section class="editor-section presentation-editor">
<div class="section-title split-section-title">
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Präsentation</p>
<h2>Darstellung in heinzlive</h2>
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
<p class="eyebrow">Nicht öffentlich</p>
<h2>Recherche & Quellen</h2>
</div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<MarkdownEditorField
name="researchText"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.researchText}
/>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Fundstellen</p>
<h3>Quellen</h3>
</div>
<button class="secondary-button" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
placeholder="Name der Quelle"
bind:value={source.label}
/></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
placeholder="Optional"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
<h2>Präsentation</h2>
</div>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">Präsentation testen</a
rel="noopener">In neuem Fenster testen</a
>
</div>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
>{/each}
</div>
{#if draft.presentationMode === 'text'}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input type="hidden" name="presentationDescription" value={draft.presentationDescription} />
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
<label class="presentation-text-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label class="wide-field"
<label
>Quelle<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
>
<option value={null}>Quelle auswählen</option>
{#each sources as source, index (index)}
{#if source.url}<option value={index}>{source.label || source.url}</option>{/if}
{/each}
</select></label
><option value={null}>Quelle auswählen</option
>{#each sources as source, index (index)}{#if source.url}<option value={index}
>{source.label || source.url}</option
>{/if}{/each}</select
></label
>
<label
>Titel<input
>Kartentitel<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
@@ -349,6 +464,7 @@
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
@@ -358,6 +474,7 @@
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
@@ -368,15 +485,21 @@
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
>
<small>
{draft.presentationMetadataFetchedAtUtc
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}
</small>
: 'Noch keine Metadaten geladen.'}</small
>
</div>
</div>
{/if}
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
content={previewContent}
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
/>
</aside>
</div>
<input
type="hidden"
name="presentationTitleManual"
@@ -401,66 +524,15 @@
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
</form>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
</div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
</section>
<div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
>Jetzt speichern</button
>
</div>
</form>
<form
bind:this={previewForm}
id="preview-load-form"
method="POST"
action="?/loadPreview"
use:enhance
>
<input type="hidden" name="revision" value={revision} />
</form>
<section class="editor-section media-editor">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
<h3>Material</h3>
</div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
@@ -499,18 +571,71 @@
</form>
</section>
<section class="editor-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<details class="template-save-menu">
<summary>Als Vorlage speichern</summary>
<form method="POST" action="?/saveTemplate">
<label
>Vorlage<select name="templateRef">
<option value="">Neue Vorlage</option>
{#each data.templates as template (template.id)}<option
value={`${template.id}|${template.revision}`}>{template.name}</option
>{/each}
</select></label
>
<label>Name<input name="templateName" required maxlength="120" /></label>
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
<button class="text-button" type="submit" disabled={actionsBlocked}
>Vorlage speichern</button
>
</form>
</details>
<form method="POST" action="?/duplicate">
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="secondary-button danger-text"
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
aria-label="Piece archivieren"
disabled={actionsBlocked}>Archivieren</button
>
</form>
</section>
</div>
<div class="piece-dialog-primary-action">
<span
class="save-indicator"
class:problem={saveState === 'error' || saveState === 'conflict'}
role="status"
aria-live="polite">{saveMessage}</span
>
<button
bind:this={saveButton}
class:primary-button={saveState === 'dirty' ||
saveState === 'error' ||
saveState === 'conflict'}
class:secondary-button={saveState === 'idle' ||
saveState === 'saved' ||
saveState === 'saving'}
type="submit"
form="piece-editor-form"
disabled={saving}
>{saveState === 'error' || saveState === 'conflict'
? 'Erneut speichern'
: 'Speichern'}</button
>
</div>
{/snippet}
</PieceEditorDialog>
<form
bind:this={previewForm}
id="preview-load-form"
method="POST"
action="?/loadPreview"
use:enhance
>
<input type="hidden" name="revision" value={revision} />
</form>
</div>
@@ -18,7 +18,7 @@ export const load: PageServerLoad = ({ locals, params }) => {
images: { id: string; altText: string }[];
} = {
mode: piece.presentationMode,
title: piece.title,
title: piece.outputTitle || piece.title,
html: renderMarkdown(piece.presentationText),
source,
previewTitle: piece.presentationTitle,
+7 -6
View File
@@ -1,9 +1,10 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPresentationView } from '#lib/server/live.js';
import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
export const load: PageServerLoad = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return { view };
export const load: PageServerLoad = ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
const view = presentationChannelAccess(params.token, accessKey)
? getPresentationChannelView(params.token)
: null;
return { view, channelId: params.token };
};
+20 -4
View File
@@ -5,8 +5,10 @@
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let view = $state(untrack(() => data.view));
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected');
let view = $state(untrack(() => data.view ?? { revision: -1, content: null }));
let connection = $state<'connected' | 'waiting' | 'interrupted'>(
untrack(() => (data.view ? 'connected' : 'waiting'))
);
onMount(() => {
let stopped = false;
@@ -26,8 +28,22 @@
}
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
}
connection = 'waiting';
timer = setTimeout(refresh, 500);
async function connect() {
const accessKey = decodeURIComponent(location.hash.slice(1));
if (accessKey) {
const response = await fetch(`/heinzlive/${data.channelId}/access`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: accessKey })
});
if (!response.ok) {
connection = 'interrupted';
return;
}
}
timer = setTimeout(refresh, 0);
}
void connect();
return () => {
stopped = true;
clearTimeout(timer);
@@ -0,0 +1,18 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presentationChannelAccess } from '#lib/server/live.js';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const body = (await request.json().catch(() => null)) as { key?: unknown } | null;
const accessKey = typeof body?.key === 'string' ? body.key : '';
if (!presentationChannelAccess(params.token, accessKey)) {
error(404, 'Präsentationsausgabe nicht gefunden');
}
cookies.set(`sendewerk-output-${params.token}`, accessKey, {
path: `/heinzlive/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: url.protocol === 'https:'
});
return json({ success: true }, { headers: { 'Cache-Control': 'no-store' } });
};
@@ -1,11 +1,13 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationMedia } from '#lib/server/live.js';
import { getPresentationMedia, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const media = await getPresentationMedia(params.token, params.id);
export const GET: RequestHandler = async ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey)) error(404, 'Bild nicht gefunden');
const media = await getPresentationMedia(accessKey, params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
return new Response(new Uint8Array(media.data), {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
@@ -1,9 +1,12 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationPreviewImage } from '#lib/server/live.js';
import { getPresentationPreviewImage, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const image = await getPresentationPreviewImage(params.token);
export const GET: RequestHandler = async ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey))
error(404, 'Vorschaubild nicht verfügbar');
const image = await getPresentationPreviewImage(accessKey);
if (!image) error(404, 'Vorschaubild nicht verfügbar');
return new Response(new Uint8Array(image.data), {
headers: {
@@ -1,9 +1,12 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationView } from '#lib/server/live.js';
import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = ({ params }) => {
const view = getPresentationView(params.token);
export const GET: RequestHandler = ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey))
error(404, 'Präsentationsausgabe nicht gefunden');
const view = getPresentationChannelView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } });
};
+33 -1
View File
@@ -4,11 +4,14 @@ import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
completeCurrent,
clearPresentationChannel,
getLiveView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent,
startOrResumeLive
} from '#lib/server/live.js';
@@ -26,6 +29,8 @@ function actionError(cause: unknown) {
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NOT_STARTED')
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
@@ -91,6 +96,18 @@ export const actions = {
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
select: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('select', () =>
selectUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
@@ -118,10 +135,25 @@ export const actions = {
const data = await request.formData();
try {
const token = issuePresentationAccess(text(data, 'showId'));
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/${token}` };
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem });
}
},
rotateOutput: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const token = rotatePresentationChannelKey(text(data, 'showId'));
return { intent: 'rotateOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'rotateOutput', ...problem });
}
},
clearOutput: async ({ locals }) => {
requireUser(locals.user);
return attempt('clearOutput', () => clearPresentationChannel());
}
} satisfies Actions;
+36 -1
View File
@@ -161,8 +161,20 @@
</form>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Präsentationsfenster öffnen</button>
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<details class="live-output-settings">
<summary>Ausgabekanal</summary>
<div>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel widerrufen und erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal leeren</button>
</form>
</div>
</details>
<div class="live-session-summary">
<strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span>
@@ -221,6 +233,17 @@
: 'Präsentieren'}
</button>
</form>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -288,6 +311,18 @@
<strong>{usage.title}</strong><small>{usage.status}</small>
</div>
<div class="live-run-actions">
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="text-button"
type="submit"
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
aria-label={`${usage.title} als aktuelles Piece auswählen`}>Aktuell</button
>
</form>
{#if usage.status === 'open'}
<form
method="POST"
+17 -1
View File
@@ -1,10 +1,12 @@
import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getTaxonomy, listPieces } from '#lib/server/content.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import {
addPieceUsage,
addShowSection,
createAndAddPieceUsage,
duplicatePieceUsage,
getShow,
getPieceUsageContexts,
@@ -123,6 +125,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
pieceTemplates: listPieceTemplates(),
createCommandId: randomUUID(),
filters: {
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '',
@@ -197,12 +201,24 @@ export const actions = {
})
);
},
createUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('createUsage', () =>
createAndAddPieceUsage(params.id, Number(data.get('revision')), text(data, 'commandId'), {
title: text(data, 'title'),
templateId: text(data, 'templateId') || 'blank',
sectionId: text(data, 'sectionId') || null
})
);
},
updateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('updateUsage', () =>
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
durationSeconds: pieceDurationInSeconds(data),
ownNotes: text(data, 'ownNotes'),
intro: text(data, 'intro'),
+69 -3
View File
@@ -22,9 +22,11 @@
let moveSectionForm: HTMLFormElement;
let moveUsageForm: HTMLFormElement;
let addUsageForm: HTMLFormElement;
let removeUsageForm: HTMLFormElement;
let draggedSectionId = $state('');
let draggedUsageId = $state('');
let draggedPieceId = $state('');
let removedUsageId = $state('');
let dropSectionId = $state('');
let dropPosition = $state(0);
let copyMessage = $state('');
@@ -165,6 +167,17 @@
setTimeout(() => moveUsageForm.requestSubmit());
}
function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) {
finishDrag();
return;
}
removedUsageId = dragged.id;
finishDrag();
setTimeout(() => removeUsageForm.requestSubmit());
}
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
const affected = formElement.closest<HTMLElement>(
'.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form'
@@ -287,8 +300,18 @@
<div class="builder-workspace">
<section
class="builder-pane source-pane"
class:drop-ready={dropTarget === 'source'}
class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title"
ondragover={(event) => {
if (dragged?.kind !== 'usage') return;
event.preventDefault();
dropTarget = 'source';
}}
ondrop={(event) => {
event.preventDefault();
removeDraggedUsage();
}}
>
<header class="pane-heading">
<div>
@@ -297,6 +320,33 @@
</div>
<span>{visiblePieces.length}</span>
</header>
{#if dropTarget === 'source'}<div class="drag-remove-target">
<strong>Aus Sendung entfernen</strong><span>Das Fundus-Original bleibt erhalten.</span>
</div>{/if}
<details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary>
<form use:enhance={enhanceCommand} method="POST" action="?/createUsage">
<input type="hidden" name="revision" value={data.show.revision} />
<input type="hidden" name="commandId" value={data.createCommandId} />
<label>Titel<input name="title" required maxlength="200" /></label>
<label
>Vorlage<select name="templateId">
{#each data.pieceTemplates as template (template.id)}<option value={template.id}
>{template.name}</option
>{/each}
</select></label
>
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}
</select></label
>
<button class="secondary-button" type="submit">Anlegen und einplanen</button>
</form>
</details>
<form id="builder-filters" method="GET" class="builder-filter">
<label
><span class="sr-only">Fundus durchsuchen</span><input
@@ -533,7 +583,7 @@
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · sendungsspezifische Kopie</small
>{formatClock(usage.durationSeconds)}</small
>
</div>
<div class="row-actions">
@@ -770,7 +820,13 @@
></label
><button class="secondary-button" type="submit">Einplanen</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/removeUsage">
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
@@ -778,7 +834,7 @@
/><button
class="text-button danger-text"
type="submit"
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button
aria-label={`${usage.title} aus Sendung entfernen`}>Aus Sendung entfernen</button
>
</form>
<UsageEditorDialog
@@ -847,4 +903,14 @@
value={draggedPieceId}
/><input type="hidden" name="sectionId" value={dropSectionId} />
</form>
<form
bind:this={removeUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} />
<input type="hidden" name="usageId" value={removedUsageId} />
</form>
</div>
+179 -62
View File
@@ -30,6 +30,16 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function openEditorialField(page: Page, heading: string) {
if (heading.includes('Recherche')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
}
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
@@ -296,8 +306,14 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
@@ -309,11 +325,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Anwenden' }).click();
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Anwenden' }).click();
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
@@ -336,7 +358,13 @@ test('sent and planned source filters stay independent and removing the last usa
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
@@ -355,7 +383,7 @@ test('sent and planned source filters stay independent and removing the last usa
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte');
await page.getByRole('button', { name: 'Anwenden' }).click();
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked();
await expect(usedPiece).toBeVisible();
await page.screenshot({
@@ -364,7 +392,7 @@ test('sent and planned source filters stay independent and removing the last usa
});
await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Filterprüfung');
await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
@@ -416,9 +444,10 @@ test('sent and planned source filters stay independent and removing the last usa
fullPage: true
});
page.once('dialog', (dialog) => dialog.accept());
await page
.locator('.reserve-usage')
.getByRole('button', { name: /aus Reserve entfernen/ })
.getByRole('button', { name: /aus Sendung entfernen/ })
.click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect(
@@ -511,9 +540,10 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
test('a show can be planned from a template with independent usages and reserve', async ({
page
}, testInfo) => {
test.slow();
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Planung Spiele und JC');
await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
@@ -574,16 +604,9 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie');
const desktopEditorItems = await page
.locator('dialog[open] .usage-dialog-editor')
.evaluate((element) =>
Array.from(element.children)
.filter((child) => child.matches('section, button'))
.map((child) => child.getBoundingClientRect().top)
);
expect(Math.abs(desktopEditorItems[0] - desktopEditorItems[1])).toBeLessThan(2);
expect(desktopEditorItems[2]).toBeGreaterThan(desktopEditorItems[1]);
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-desktop.png'),
fullPage: true
@@ -603,9 +626,14 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt');
await usages.nth(0).getByLabel('Titel', { exact: true }).fill('Konsole als Auftakt');
await usages.nth(0).locator('.piece-meta-details > summary').click();
await usages.nth(0).getByLabel('Dauer in Minuten').fill('5');
await usages.nth(0).getByLabel('Eigene Notizen').fill('Kurbel zuerst zeigen');
await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click();
await usages
.nth(0)
.getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true })
.fill('Kurbel zuerst zeigen');
await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt');
@@ -619,9 +647,14 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick');
await usages.nth(1).getByLabel('Titel', { exact: true }).fill('Konsole als Rückblick');
await usages.nth(1).locator('.piece-meta-details > summary').click();
await usages.nth(1).getByLabel('Dauer in Minuten').fill('15');
await usages.nth(1).getByLabel('Eigene Notizen').fill('Andere Perspektive');
await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click();
await usages
.nth(1)
.getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true })
.fill('Andere Perspektive');
await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
@@ -651,7 +684,7 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie');
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible();
@@ -679,7 +712,6 @@ test('a show can be planned from a template with independent usages and reserve'
);
await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveHandle.dispatchEvent('dragend', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
@@ -689,7 +721,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen');
await expect(savedUsage.locator('textarea[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const duplicatedUsage = page
.locator('.editable-usage')
@@ -700,7 +732,7 @@ test('a show can be planned from a template with independent usages and reserve'
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15');
await expect(duplicatedUsage.nth(1).getByLabel('Eigene Notizen')).toHaveValue(
await expect(duplicatedUsage.nth(1).locator('textarea[name="ownNotes"]')).toHaveValue(
'Andere Perspektive'
);
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
@@ -709,15 +741,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
const mobileEditorItems = await page
.locator('dialog[open] .usage-dialog-editor')
.evaluate((element) =>
Array.from(element.children)
.filter((child) => child.matches('section, button'))
.map((child) => child.getBoundingClientRect().top)
);
expect(mobileEditorItems[1]).toBeGreaterThan(mobileEditorItems[0]);
expect(mobileEditorItems[2]).toBeGreaterThan(mobileEditorItems[1]);
await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-mobile.png'),
@@ -725,18 +749,16 @@ test('a show can be planned from a template with independent usages and reserve'
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Entfernen' })
.getByRole('button', { name: 'Aus Sendung entfernen' })
.scrollIntoViewIfNeeded();
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Entfernen' })
page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' })
).toBeVisible();
await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
.evaluate((element) => ((element as HTMLInputElement).value = '1'));
await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('dialog[open]').getByRole('alert')).toContainText(
'inzwischen geändert'
);
await expect(page.getByRole('alert')).toContainText('inzwischen geändert');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
@@ -853,9 +875,8 @@ test('validated YouTube and TikTok players switch without exposing private field
);
await page.goto('/live?show=e2e-presentation');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click();
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
const output = await popupPromise;
await output.waitForLoadState('networkidle');
await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible();
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
@@ -900,6 +921,7 @@ test('live control persists state and exposes only explicit presentation data',
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
@@ -934,9 +956,9 @@ test('live control persists state and exposes only explicit presentation data',
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click()
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
]);
await popup.waitForURL(/\/heinzlive\/[A-Za-z0-9_-]+$/);
await popup.waitForURL(/\/heinzlive\/default#/);
const outputUrl = popup.url();
const anonymous = await browser.newContext();
const output = await anonymous.newPage();
@@ -952,14 +974,17 @@ test('live control persists state and exposes only explicit presentation data',
timeout: 10_000
});
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0);
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
const outputHtml = await output.content();
expect(outputHtml).not.toContain('Herstellerangaben');
expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
expect(outputHtml).not.toContain(adminEmail);
expect(outputHtml).not.toContain('live-konsole.png');
const outputState = await anonymous.request.get(`${outputUrl}/state`);
const stateUrl = new URL(outputUrl);
stateUrl.hash = '';
stateUrl.pathname += '/state';
const outputState = await anonymous.request.get(stateUrl.toString());
expect(outputState.status()).toBe(200);
const outputJson = await outputState.text();
expect(outputJson).toContain('Weniger Leistung');
@@ -989,9 +1014,11 @@ test('live control persists state and exposes only explicit presentation data',
await page.evaluate(() => {
window.open = () => null;
});
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click();
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await expect(
page.getByText('Popup blockiert. Bitte Popups erlauben und erneut öffnen.')
page
.getByRole('status')
.filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' })
).toBeVisible();
await anonymous.close();
await popup.close();
@@ -1004,26 +1031,41 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus');
await page.getByText('Neues Piece anlegen').click();
await page.locator('.template-picker > summary').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url();
await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown');
await page.getByRole('link', { name: '← Fundus', exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück mit Markdown');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page.getByText('Status, Dauer und Einordnung').click();
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check();
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Private Recherche');
await page
.getByLabel('Präsentationstext')
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Den Kern in zwei Sätzen erklären.');
await openEditorialField(page, 'Private Recherche');
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
await openEditorialField(page, 'Präsentationstext');
await page
.getByRole('textbox', { name: 'Öffentlicher Text', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag');
@@ -1032,12 +1074,15 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await openEditorialField(page, 'Präsentationstext');
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await expect(page.getByLabel('Titel', { exact: true })).toHaveValue('E2E Fundstück mit Markdown');
await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
await openEditorialField(page, 'Private Recherche');
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
@@ -1065,8 +1110,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -1074,7 +1120,10 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
timeout: 10_000
});
await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await expect(concurrent.getByLabel('Titel', { exact: true })).toHaveValue(
'E2E Fundstück, extern aktualisiert'
);
await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
@@ -1085,7 +1134,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible();
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }).click();
await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
@@ -1094,14 +1143,26 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card .piece-actions > summary').click();
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(page.getByLabel('Titel')).toHaveValue(
await expect(page.getByLabel('Titel', { exact: true })).toHaveValue(
'Kopie von E2E Fundstück, extern aktualisiert'
);
await page.context().setOffline(true);
await page.getByLabel('Titel', { exact: true }).fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000
});
await page.context().setOffline(false);
await page.getByRole('button', { name: 'Erneut speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
@@ -1109,23 +1170,77 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
});
});
test('a quickly captured video can be written and previewed before classification', async ({
page
}) => {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>YouTube Test</title>'
})
);
await page.goto('/eingang');
await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Private Recherche');
await page
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
);
await preview.close();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
page
}, testInfo) => {
await page.goto('/fundus/piece-console');
await page.getByLabel('Linkvorschau').check();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau'
);
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Präsentation testen' }).click();
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await preview.waitForLoadState('networkidle');
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
@@ -1146,8 +1261,10 @@ test('link preview uses one existing source, persists manual fields and isolates
});
await preview.close();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview')
);