7 Commits
Author SHA1 Message Date
hnzio c3033479b5 Release 0.5.9 2026-10-23 13:16:32 +02:00
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
46 changed files with 5056 additions and 1308 deletions

No files matched your search

+155
View File
@@ -215,3 +215,158 @@
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um, Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich - `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
## Live-Werkbank 0.5.9
| Kriterium | Nachweis | Stand |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------- |
| Favorit ist globale Originaleigenschaft | Service prüft Revision/Status; Browser prüft Stern, Sortierung und weiterhin wirksamen Planfilter | erfüllt am 23.10.2026 |
| Werkzeugkatalog und Rechte | Barkeeper-Verwaltung serverseitig geschützt; Browser prüft Anlage, Formatbezug und Deaktivierung | erfüllt am 23.10.2026 |
| Deaktivierte Zuordnung bleibt bearbeitbar | Browser öffnet das Piece erneut und findet Werkzeug, Arbeitslink, Notiz und Deaktivierungsstatus | erfüllt am 23.10.2026 |
| Relationen werden unveränderlich gesnapshottet | Service ändert Quelle, Werkzeug und Thema und sieht Änderungen erst nach expliziter Aktualisierung | erfüllt am 23.10.2026 |
| Private Live-Materialien bleiben privat | Live-Steuerung zeigt Snapshot-Quelle; anonymes HTML/JSON enthält den privaten Materiallink nicht | erfüllt am 23.10.2026 |
| Thematische Publikum-Dachzeile | Usage-Themen werden stabil projiziert; Chromium zeigt „Gaming“ oberhalb des Präsentationstitels | erfüllt am 23.10.2026 |
| Persistenter Abschnitts-Countdown | kontrollierte Testzeit prüft eingefrorenes Soll, Reload, Piece-Wechsel und Abschluss ohne Reset | erfüllt am 23.10.2026 |
| Responsive Bedienung | komplette Browsermatrix und gezielter Werkzeugeditor bei 390 × 844 ohne horizontalen Überlauf | 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.9
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 37 Tests in 7 Dateien erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
- zusätzlicher gezielter Chromium-Lauf des Werkzeugeditors bei 390 × 844 Pixeln:
1 von 1 erfolgreich, ohne horizontalen Überlauf
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, Planung, Live-Steuerung, anonymer Ausgabe und privater Materialien
erfolgreich; Screenshots wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- lokale Entwicklungsdatenbank vorab nach
`.data/backups/0.5.9-pre-migration/sendewerk.sqlite` gesichert;
`npm run db:migrate` hat `0009_live_workbench.sql` erfolgreich angewendet
- `bash -n start.sh` und `git diff --check`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen
+51 -19
View File
@@ -37,18 +37,18 @@ Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
und spätere Bereiche. und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | 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 | | 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, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz | | Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | | Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | | Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | | Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | | RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | | API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | | Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel ### Historien- und Kopierregel
@@ -64,6 +64,11 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt. getrennt.
Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt in
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
## Anmeldung und Rechte ## Anmeldung und Rechte
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
@@ -97,11 +102,23 @@ erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständi
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt. unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
`is_favorite` ist eine globale Eigenschaft des Originals und keine Nutzung oder
Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate
sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten
Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle
bestehenden Zuordnungen erhält.
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
diese Felder nicht. 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 Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision. Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
@@ -130,7 +147,9 @@ weitere Bearbeitung geschützt.
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke, Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
Metadaten und validierte Anbieter-ID. Eigene Notizen liegen ausschließlich an Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz sowie die
Themennamen. Eigene Notizen liegen ausschließlich an
der Verwendung. Die Referenz zum Original bleibt für Historie und der Verwendung. Die Referenz zum Original bleibt für Historie und
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
@@ -161,6 +180,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 Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern. 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 Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
@@ -191,12 +215,20 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de
geplanten Dauer nachvollziehbar. geplanten Dauer nachvollziehbar.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im
neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören. 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. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
Uploads werden nicht veröffentlicht; nur Themennamen sowie 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 Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
+58
View File
@@ -276,3 +276,61 @@ Quellen:
- <https://developers.tiktok.com/doc/embed-player> - <https://developers.tiktok.com/doc/embed-player>
- <https://developers.tiktok.com/doc/embed-videos> - <https://developers.tiktok.com/doc/embed-videos>
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery> - <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.
## D-022: Werkzeuge und Live-Abschnittszeit als eigene Snapshot-Grenzen
Favoriten bleiben ein boolesches Merkmal des Fundus-Originals. Sie ändern weder
Revision, Status noch Verwendungshistorie und werden nicht pro Konto
dupliziert. Der Werkzeugkatalog ist eine eigene Entität: Formatbeziehungen
beschreiben nur die Eignung, während Piece-Zuordnungen den privaten Arbeitslink
und die Notiz tragen. Katalogeinträge werden deaktiviert statt gelöscht, damit
bestehende Piece- und Sendungsbezüge lesbar bleiben.
Beim Einplanen kopieren eigene Relationstabellen alle Quellen, Werkzeuge und
sortierten Themennamen an die Verwendung. Damit können Katalognamen, URLs,
Themen und Originalzuordnungen später geändert werden, ohne eine vorbereitete
Sendung still umzuschreiben. Nur die bewusste Aktion **Aus Fundus aktualisieren**
ersetzt diese Snapshots. Die öffentliche Positivliste darf daraus ausschließlich
die Themen-Dachzeile übernehmen; private Materiallinks und Werkzeugdaten bleiben
auf der authentifizierten Live-Route.
Der Abschnitts-Countdown verwendet von den Piece-Laufintervallen getrennte
`live_section_timers` und `live_section_timer_runs`. Beim ersten tatsächlichen
Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere
Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen
ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene
Piece-Zeiterfassung doppelt zu zählen.
+153 -10
View File
@@ -7,6 +7,137 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen. Etappe 6 wurde nicht begonnen.
## Live-Werkbank 0.5.9
- globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete
Favoriten bleiben sichtbar, geplante Favoriten respektieren weiterhin den
Einplanungsfilter
- Barkeeper-Werkzeugkatalog mit Start-URL, Beschreibung, passenden Formaten und
verlustfreier Deaktivierung; Arbeitslink und Notiz bleiben für die gesamte
Thekencrew im gemeinsamen Piece- und Verwendungseditor bearbeitbar
- vollständige, voneinander unabhängige Usage-Snapshots für Quellen, Werkzeuge
und alphabetisch stabilisierte Themen; spätere Original- oder
Katalogänderungen wirken erst nach bewusster Aktualisierung aus dem Fundus
- kompakte private Materialliste in der Live-Steuerung mit Arbeitslink vor
Start-URL und verständlichem Zustand ohne URL
- responsive Themen-Dachzeile in der Publikumsausgabe; ohne Thema bleibt sie
vollständig aus, während Präsentations- und interner Titel getrennt bleiben
- persistenter Abschnitts-Countdown für positive explizite Abschnittsdauern mit
eingefrorenem Soll, sekündlicher Anzeige, Goldwarnung ab zwei Minuten und
roter Überziehung ohne Ton, Blinken oder automatische Weiterschaltung
Etappe 6 bleibt unverändert unbegonnen. Reales OBS und echte
YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
Die lokale Prüfmatrix umfasst 37 Verhaltenstests und 16 Chromium-Abläufe.
Desktop, Smartphone, Light/Dark, Favoritenfilter, Werkzeug-Deaktivierung,
Snapshot-Stabilität, private Live-Materialien, öffentliche Themenprojektion und
persistente Abschnittszeit wurden geprüft.
## 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 ## Umgesetzter Stand Etappe 1
- SvelteKit-/TypeScript-Monolith mit Node-Adapter - SvelteKit-/TypeScript-Monolith mit Node-Adapter
@@ -71,16 +202,28 @@ als bereits implementiert verstanden werden.
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht - standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- der eigenständige Piece-Editor ist als redaktionelles Arbeitsblatt aufgebaut: - Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
Titel, Fundstück, private Recherche und die getrennt gespeicherten großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
Sendungstexte stehen im Schreibfluss; Quellen, Einordnung, Präsentation und Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
Bilder begleiten ihn als Desktop-Kontextleiste oder mobile Werkzeuge Original sowie Verwendung bleiben getrennte Revisionsgrenzen
- Markdown-Felder wechseln einzeln zwischen Lesen und Bearbeiten, sodass mehrere - Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
Textbereiche gleichzeitig offen bleiben; Autosave, Konfliktschutz und vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
sichtbare Speicherfehler verwenden unverändert die Piece-Revision Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
- die Schnellaufnahme „Aufnehmen und öffnen“ leitet nach erfolgreicher Anlage - Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
direkt zum neuen Piece weiter, statt den SvelteKit-Redirect als Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
Validierungsfehler abzufangen 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 ## 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;
+102
View File
@@ -0,0 +1,102 @@
ALTER TABLE pieces ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0
CHECK (is_favorite IN (0, 1));
CREATE TABLE tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
start_url TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE tool_formats (
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
format_id TEXT NOT NULL REFERENCES formats(id) ON DELETE CASCADE,
PRIMARY KEY (tool_id, format_id)
);
CREATE TABLE piece_tools (
id TEXT PRIMARY KEY,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE RESTRICT,
work_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
UNIQUE (piece_id, tool_id),
UNIQUE (piece_id, position)
);
CREATE TABLE usage_source_snapshots (
id TEXT PRIMARY KEY,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
label TEXT NOT NULL,
url TEXT NOT NULL,
timecode_seconds INTEGER CHECK (timecode_seconds IS NULL OR timecode_seconds >= 0),
position INTEGER NOT NULL CHECK (position >= 0),
UNIQUE (usage_id, position)
);
CREATE TABLE usage_tool_snapshots (
id TEXT PRIMARY KEY,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
original_tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL,
name TEXT NOT NULL,
start_url TEXT NOT NULL DEFAULT '',
work_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
UNIQUE (usage_id, position)
);
CREATE TABLE usage_topic_snapshots (
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL CHECK (position >= 0),
PRIMARY KEY (usage_id, position)
);
CREATE TABLE live_section_timers (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
section_id TEXT NOT NULL REFERENCES show_sections(id) ON DELETE CASCADE,
planned_duration_seconds INTEGER NOT NULL CHECK (planned_duration_seconds > 0),
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE TABLE live_section_timer_runs (
id TEXT PRIMARY KEY,
timer_id TEXT NOT NULL REFERENCES live_section_timers(id) ON DELETE CASCADE,
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE INDEX idx_piece_tools_piece ON piece_tools(piece_id, position);
CREATE INDEX idx_tool_formats_format ON tool_formats(format_id, tool_id);
CREATE INDEX idx_usage_sources_usage ON usage_source_snapshots(usage_id, position);
CREATE INDEX idx_usage_tools_usage ON usage_tool_snapshots(usage_id, position);
CREATE UNIQUE INDEX idx_one_open_section_timer_per_show
ON live_section_timers(show_id) WHERE ended_at_utc IS NULL;
CREATE UNIQUE INDEX idx_one_open_run_per_section_timer
ON live_section_timer_runs(timer_id) WHERE ended_at_utc IS NULL;
INSERT INTO usage_source_snapshots (
id, usage_id, label, url, timecode_seconds, position
)
SELECT lower(hex(randomblob(16))), u.id, ps.label, ps.url, ps.timecode_seconds, ps.position
FROM piece_usages u
JOIN piece_sources ps ON ps.piece_id = u.original_piece_id;
INSERT INTO usage_topic_snapshots (usage_id, name, position)
SELECT usage_id, name, position
FROM (
SELECT u.id AS usage_id, t.name,
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
FROM piece_usages u
JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
JOIN topics t ON t.id = pt.topic_id
);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.2", "version": "0.5.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.2", "version": "0.5.9",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.2", "version": "0.5.9",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+22
View File
@@ -425,6 +425,28 @@ export function seed(databasePath = getPaths().database) {
] ]
] ]
); );
database.prepare('DELETE FROM usage_source_snapshots').run();
database
.prepare(
`INSERT INTO usage_source_snapshots
(id, usage_id, label, url, timecode_seconds, position)
SELECT 'seed-source-' || u.id || '-' || ps.position, u.id, ps.label, ps.url,
ps.timecode_seconds, ps.position
FROM piece_usages u JOIN piece_sources ps ON ps.piece_id = u.original_piece_id`
)
.run();
database.prepare('DELETE FROM usage_topic_snapshots').run();
database
.prepare(
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
SELECT usage_id, name, position FROM (
SELECT u.id AS usage_id, t.name,
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
FROM piece_usages u JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
JOIN topics t ON t.id = pt.topic_id
)`
)
.run();
}); });
seedDatabase(); seedDatabase();
+1323 -153
View File
File diff suppressed because it is too large. Load diff
@@ -1,60 +0,0 @@
<script lang="ts">
import MarkdownPreview from './MarkdownPreview.svelte';
let {
name,
label,
inputLabel = label,
hint = '',
emptyText,
value = $bindable(),
html,
privateField = false,
rows = 7
}: {
name: string;
label: string;
inputLabel?: string;
hint?: string;
emptyText: string;
value: string;
html: string;
privateField?: boolean;
rows?: number;
} = $props();
let editing = $state(false);
</script>
<section class:private-field={privateField} class:empty={!value.trim()} class="editorial-field">
<header>
<div>
<h3>{label}</h3>
{#if hint}<p>{hint}</p>{/if}
</div>
<button
class="text-button editorial-edit-button"
type="button"
aria-expanded={editing}
onclick={() => (editing = !editing)}
>
{editing ? 'Lesen' : value.trim() ? 'Bearbeiten' : 'Schreiben'}
</button>
</header>
{#if editing}
<label class="editorial-input">
<span class="sr-only">{inputLabel}</span>
<textarea aria-label={inputLabel} {name} {rows} maxlength="50000" bind:value></textarea>
</label>
{:else}
<input type="hidden" {name} {value} />
{#if value.trim()}
<MarkdownPreview {html} />
{:else}
<button class="editorial-empty" type="button" onclick={() => (editing = true)}>
<span>{emptyText}</span><small>Zum Schreiben öffnen</small>
</button>
{/if}
{/if}
</section>
@@ -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>
@@ -41,7 +41,7 @@
{#if content.mode === 'text'} {#if content.mode === 'text'}
<article class="output-content output-text"> <article class="output-content output-text">
<p class="output-kicker">heinzlive</p> {#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
<h1>{content.title}</h1> <h1>{content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. --> <!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
@@ -66,6 +66,7 @@
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} /> <img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
</div>{/if} </div>{/if}
<div class="output-link-copy"> <div class="output-link-copy">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p> <p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
<h1>{content.previewTitle || content.title}</h1> <h1>{content.previewTitle || content.title}</h1>
{#if content.description}<p>{content.description}</p>{/if} {#if content.description}<p>{content.description}</p>{/if}
@@ -79,6 +80,7 @@
{:else} {:else}
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video"> <article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
<header> <header>
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p> <p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
<h1>{content.previewTitle || content.title}</h1> <h1>{content.previewTitle || content.title}</h1>
</header> </header>
+302 -155
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
let { let {
@@ -19,12 +21,32 @@
error?: string; error?: string;
enhanceCommand: SubmitFunction; enhanceCommand: SubmitFunction;
} = $props(); } = $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, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
function durationInMinutes(seconds: number) { function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2)); 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> </script>
<button <button
@@ -32,45 +54,77 @@
type="button" type="button"
aria-label={`${usage.title} bearbeiten und verschieben`} aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben" title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()} onclick={() => editor.showModal()}
> >
<NavIcon name="edit" /> <NavIcon name="edit" />
</button> </button>
<dialog <PieceEditorDialog
bind:this={dialog} bind:this={editor}
class="usage-editor-dialog" bind:activeSection
aria-labelledby={`usage-editor-title-${usage.id}`} labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()} onrequestclose={requestClose}
> >
<header> {#snippet header()}
<div> <p class="eyebrow">Sendungsfassung</p>
<p class="eyebrow">Sendungsspezifische Kopie</p> <label class="piece-dialog-title">
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2> <span class="sr-only">Titel</span>
</div> <textarea
<form method="dialog"> form={formId}
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button> name="title"
</form> required
</header> rows="1"
<div class="usage-dialog-scroll"> maxlength="200"
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if} aria-label="Titel"
<form bind:value={draft.title}
use:enhance={enhanceCommand} oninput={() => (dirty = true)}></textarea>
method="POST" </label>
action="?/updateUsage" <h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
class="usage-dialog-editor" {/snippet}
>
<input type="hidden" name="revision" value={revision} /> <form
<input type="hidden" name="usageId" value={usage.id} /> id={formId}
<section class="editor-section"> use:enhance={enhanceEditor}
<div class="section-title"> method="POST"
<p class="eyebrow">Grundlagen</p> action="?/updateUsage"
<h3>Titel, Dauer und Notizen</h3> 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="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>
<div class="editor-fields two-columns"> </div>
<label class="wide-field" <div class="piece-writing-stack">
>Titel<input name="title" required maxlength="200" value={usage.title} /></label <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 <label
>Dauer in Minuten<input >Dauer in Minuten<input
name="durationMinutes" name="durationMinutes"
@@ -81,148 +135,241 @@
value={durationInMinutes(usage.durationSeconds)} value={durationInMinutes(usage.durationSeconds)}
/></label /></label
> >
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div> </div>
</section> </details>
</section>
<section class="editor-section markdown-section"> <section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="section-title"> <div class="piece-panel-heading">
<p class="eyebrow">Redaktion</p> <div>
<h3>Getrennte Arbeitsbereiche</h3> <p class="eyebrow">Nicht öffentlich</p>
<h3>Recherche & Quellen</h3>
</div> </div>
<div class="markdown-grid"> <small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
<label </div>
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000" <div class="piece-writing-stack">
>{usage.intro}</textarea <MarkdownEditorField
></label name="research"
> label="Private Recherche"
<label class="private-field" placeholder="Hintergrund, Prüfung und offene Fragen"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000" rows={10}
>{usage.research}</textarea privateField
></label bind:value={draft.research}
> />
<label <MarkdownEditorField
><span>Moderationsstichpunkte</span><textarea name="ownNotes"
name="moderation" label="Interne Notizen zu dieser Verwendung"
rows="7" placeholder="Absprachen und Hinweise nur für diese Sendung"
maxlength="50000">{usage.moderation}</textarea privateField
></label bind:value={draft.ownNotes}
> />
<label </div>
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000" <section class="piece-source-summary">
>{usage.transition}</textarea <p class="eyebrow">Snapshot-Quellen</p>
></label {#if draft.sources.length}
> {#each draft.sources as source (source.id)}<div>
<label class="wide-field" <strong>{source.label}</strong><small>{source.url}</small>
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000" </div>{/each}
>{usage.presentation}</textarea {:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
></label </section>
> <section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Snapshot</p>
<h3>Werkzeuge</h3>
</div>
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
</div> </div>
<div class="presentation-settings"> {#if draft.tools.length}<div class="source-list">
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
<input type="hidden" name="usageToolId" value={tool.id} />
<div class="tool-assignment-name">
<strong>{tool.name}</strong>{#if tool.startUrl}<small>{tool.startUrl}</small>{/if}
</div>
<label
>Arbeitslink<input
name="toolWorkUrl"
type="url"
maxlength="2000"
placeholder={tool.startUrl || 'https://…'}
bind:value={tool.workUrl}
/></label
>
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
>
</div>{/each}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</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 <label
>Darstellung<select name="presentationMode" bind:value={presentationMode}> >Präsentationstitel<input
<option value="text">Text</option> name="outputTitle"
<option value="link">Linkvorschau</option> maxlength="300"
<option value="video">Video</option> placeholder="Optional, sonst interner Titel"
</select></label bind:value={draft.outputTitle}
/></label
> >
{#if presentationMode !== 'text'} <MarkdownEditorField
<input name="presentation"
type="hidden" label="Öffentlicher Text"
name="presentationSourceLabel" placeholder="Text für die Publikumsausgabe"
value={usage.presentationSourceLabel} bind:value={draft.presentation}
/> />
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} /> <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
<input {#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
type="hidden" <label
name="presentationSourceTimecodeSeconds" ><input
value={usage.presentationSourceTimecodeSeconds ?? ''} type="radio"
/> name="presentationMode"
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} /> value={mode[0]}
<div class="presentation-source-summary wide-field"> bind:group={draft.presentationMode}
<span>Ausgewählte Quelle</span> />{mode[1]}</label
<strong >
>{usage.presentationSourceLabel || {/each}
usage.presentationSourceUrl || </div>
'Keine Quelle'}</strong <input
type="hidden"
name="presentationSourceLabel"
value={draft.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<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
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Kartentitel<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}></textarea></label
> >
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
</div> </div>
<label {:else}
>Titel<input <input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
name="presentationTitle" <input
maxlength="300" type="hidden"
value={usage.presentationTitle} name="presentationDescription"
/></label value={draft.presentationDescription}
> />
<label <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>
{/if} {/if}
</div> </div>
</section> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<button class="primary-button" type="submit">Verwendung speichern</button> <h4>{draft.outputTitle || draft.title}</h4>
</form> {#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
<section class="usage-dialog-actions" aria-label="Weitere Aktionen"> {draft.presentation || 'Noch kein öffentlicher Text.'}
<div> </p>
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage"> {:else}
<input type="hidden" name="revision" value={revision} /> {#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
<input type="hidden" name="usageId" value={usage.id} /> <p>
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId} {draft.presentationDescription ||
>Aus Fundus aktualisieren</button draft.presentationSourceUrl ||
> 'Noch keine Vorschauangaben.'}
</form> </p>
<small {/if}
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small </aside>
>
</div> </div>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage"> </section>
</form>
{#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="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit">Duplizieren</button> <button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>
</form>
<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="text-button" type="submit" disabled={dirty}>Duplizieren</button>
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} /><input
<input type="hidden" name="usageId" value={usage.id} /> type="hidden"
<input type="hidden" name="position" value="9999" /> name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" />
<label <label
>Ziel<select name="sectionId"> ><span class="sr-only">Ziel</span><select name="sectionId"
<option value="">Reservebank</option> ><option value="">Reservebank</option>{#each sections as target (target.id)}<option
{#each sections as target (target.id)} value={target.id}
<option value={target.id} selected={target.id === sectionId}>{target.name}</option> selected={target.id === sectionId}>{target.name}</option
{/each} >{/each}</select
</select></label ></label
> >
<button class="secondary-button" type="submit">Verschieben</button> <button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
</form> </form>
<form <form
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
method="POST" method="POST"
action="?/removeUsage" action="?/removeUsage"
onsubmit={(event) => 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="revision" value={revision} /><input
<input type="hidden" name="usageId" value={usage.id} /> type="hidden"
<button class="text-button danger-text" type="submit">Entfernen</button> name="usageId"
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>
</form> </form>
</section> </div>
</div> <div class="piece-dialog-primary-action">
</dialog> {#if error}<span class="problem" role="alert">{error}</span>{/if}
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
</div>
{/snippet}
</PieceEditorDialog>
+55
View File
@@ -12,10 +12,13 @@ import {
getMediaFile, getMediaFile,
getPiece, getPiece,
getTaxonomy, getTaxonomy,
listPieceTemplates,
listPieces, listPieces,
loadPiecePresentationMetadata, loadPiecePresentationMetadata,
savePiece, savePiece,
savePieceAsTemplate,
setPieceArchived, setPieceArchived,
setPieceFavorite,
uploadPieceImage, uploadPieceImage,
type PieceInput type PieceInput
} from './content.js'; } from './content.js';
@@ -42,6 +45,7 @@ afterEach(() => {
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput { function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return { return {
title: 'Getestetes Piece', title: 'Getestetes Piece',
outputTitle: '',
summary: 'Eine persistente Kurzbeschreibung.', summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk', formatId: 'format-talk',
status: 'ready', status: 'ready',
@@ -73,6 +77,57 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
} }
describe('content management', () => { describe('content management', () => {
it('sorts global favorites first without changing their revision', () => {
const before = getPiece('piece-console')!;
setPieceFavorite('piece-console', true);
const pieces = listPieces({ statuses: ['ready'] });
expect(pieces[0]).toMatchObject({ id: 'piece-console', isFavorite: true });
expect(getPiece('piece-console')).toMatchObject({
isFavorite: true,
revision: before.revision,
status: before.status
});
});
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', () => { it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk'); const id = createPiece('talk');
const initial = getPiece(id)!; const initial = getPiece(id)!;
+225 -79
View File
@@ -21,8 +21,22 @@ export type PieceSourceInput = {
timecodeSeconds: number | null; timecodeSeconds: number | null;
}; };
export type PieceToolInput = {
toolId: string;
workUrl: string;
note: string;
};
export type PieceTool = PieceToolInput & {
id: string;
name: string;
startUrl: string;
isActive: boolean;
};
export type PieceInput = { export type PieceInput = {
title: string; title: string;
outputTitle: string;
summary: string; summary: string;
formatId: string | null; formatId: string | null;
status: EditablePieceStatus; status: EditablePieceStatus;
@@ -47,6 +61,7 @@ export type PieceInput = {
topicIds: string[]; topicIds: string[];
categoryIds: string[]; categoryIds: string[];
sources: PieceSourceInput[]; sources: PieceSourceInput[];
tools?: PieceToolInput[];
}; };
export type PieceListItem = { export type PieceListItem = {
@@ -62,15 +77,18 @@ export type PieceListItem = {
topics: { id: string; name: string }[]; topics: { id: string; name: string }[];
sourceCount: number; sourceCount: number;
mediaId: string | null; mediaId: string | null;
isFavorite: boolean;
}; };
export type PieceEditor = Omit<PieceInput, 'status'> & { export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & {
id: string; id: string;
status: PieceStatus; status: PieceStatus;
revision: number; revision: number;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: string; updatedAtUtc: string;
media: PieceMedia[]; media: PieceMedia[];
isFavorite: boolean;
tools: PieceTool[];
}; };
export type PieceMedia = { export type PieceMedia = {
@@ -88,30 +106,12 @@ export type Taxonomy = {
topics: { id: string; name: string }[]; topics: { id: string; name: string }[];
}; };
export const pieceTemplates = [ export type PieceTemplate = {
{ id: string;
id: 'blank', name: string;
name: 'Leer starten', description: string;
description: 'Ein freies Piece ohne Vorgaben.' revision: number;
}, };
{
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'];
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([ const ALLOWED_IMAGES = new Map([
@@ -140,6 +140,32 @@ function validUrl(value: string): boolean {
} }
} }
function cleanPieceTools(values: PieceToolInput[] = []): PieceToolInput[] {
if (values.length > 20) throw new Error('INVALID_TOOLS');
const ids = uniqueIds(values.map((value) => value.toolId));
if (ids.length !== values.length) throw new Error('INVALID_TOOLS');
if (ids.length) {
const placeholders = ids.map(() => '?').join(', ');
const row = getDatabase()
.prepare(`SELECT count(*) AS count FROM tools WHERE id IN (${placeholders})`)
.get(...ids) as { count: number };
if (row.count !== ids.length) throw new Error('INVALID_TOOLS');
}
return values.map((value) => {
const workUrl = cleanText(value.workUrl, 2_000, 'tools');
if (workUrl) {
if (!validUrl(workUrl)) throw new Error('INVALID_TOOLS');
const parsed = new URL(workUrl);
if (parsed.username || parsed.password) throw new Error('INVALID_TOOLS');
}
return {
toolId: value.toolId,
workUrl,
note: cleanText(value.note, 2_000, 'tools')
};
});
}
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) { function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
if (!ids.length) return; if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', '); const placeholders = ids.map(() => '?').join(', ');
@@ -202,6 +228,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return { return {
title, title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'), summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null, formatId: input.formatId || null,
status: input.status, status: input.status,
@@ -229,7 +256,8 @@ function validatePieceInput(input: PieceInput): PieceInput {
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null, presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
topicIds, topicIds,
categoryIds, categoryIds,
sources sources,
tools: cleanPieceTools(input.tools)
}; };
} }
@@ -251,6 +279,7 @@ export function getTaxonomy(): Taxonomy {
type PieceListRow = { type PieceListRow = {
id: string; id: string;
title: string; title: string;
output_title: string;
summary: string; summary: string;
status: PieceStatus; status: PieceStatus;
revision: number; revision: number;
@@ -259,6 +288,7 @@ type PieceListRow = {
format: string; format: string;
source_count: number; source_count: number;
media_id: string | null; media_id: string | null;
is_favorite: number;
}; };
export function listPieces(filters: { export function listPieces(filters: {
@@ -303,13 +333,14 @@ export function listPieces(filters: {
const database = getDatabase(); const database = getDatabase();
const rows = database const rows = database
.prepare( .prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format, p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count, (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END, ORDER BY p.is_favorite DESC,
CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
p.updated_at_utc DESC, p.title` p.updated_at_utc DESC, p.title`
) )
.all(...parameters) as PieceListRow[]; .all(...parameters) as PieceListRow[];
@@ -334,13 +365,15 @@ export function listPieces(filters: {
categories: categoryStatement.all(row.id) as PieceListItem['categories'], categories: categoryStatement.all(row.id) as PieceListItem['categories'],
topics: topicStatement.all(row.id) as PieceListItem['topics'], topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count, sourceCount: row.source_count,
mediaId: row.media_id mediaId: row.media_id,
isFavorite: row.is_favorite === 1
})); }));
} }
type PieceRow = { type PieceRow = {
id: string; id: string;
title: string; title: string;
output_title: string;
summary: string; summary: string;
format_id: string | null; format_id: string | null;
status: PieceStatus; status: PieceStatus;
@@ -365,19 +398,20 @@ type PieceRow = {
revision: number; revision: number;
created_at_utc: string; created_at_utc: string;
updated_at_utc: string; updated_at_utc: string;
is_favorite: number;
}; };
export function getPiece(id: string): PieceEditor | null { export function getPiece(id: string): PieceEditor | null {
const database = getDatabase(); const database = getDatabase();
const row = database const row = database
.prepare( .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, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_provider_id, presentation_title_manual, presentation_description_manual,
presentation_image_manual, presentation_metadata_fetched_at_utc, presentation_image_manual, presentation_metadata_fetched_at_utc,
revision, created_at_utc, updated_at_utc revision, created_at_utc, updated_at_utc, is_favorite
FROM pieces WHERE id = ?` FROM pieces WHERE id = ?`
) )
.get(id) as PieceRow | undefined; .get(id) as PieceRow | undefined;
@@ -385,6 +419,7 @@ export function getPiece(id: string): PieceEditor | null {
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
outputTitle: row.output_title,
summary: row.summary, summary: row.summary,
formatId: row.format_id, formatId: row.format_id,
status: row.status, status: row.status,
@@ -409,6 +444,7 @@ export function getPiece(id: string): PieceEditor | null {
revision: row.revision, revision: row.revision,
createdAtUtc: row.created_at_utc, createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc, updatedAtUtc: row.updated_at_utc,
isFavorite: row.is_favorite === 1,
topicIds: ( topicIds: (
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as { database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
topic_id: string; topic_id: string;
@@ -451,6 +487,31 @@ export function getPiece(id: string): PieceEditor | null {
byteSize: media.byte_size, byteSize: media.byte_size,
altText: media.alt_text, altText: media.alt_text,
position: media.position position: media.position
})),
tools: (
database
.prepare(
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
WHERE pt.piece_id = ? ORDER BY pt.position`
)
.all(id) as {
id: string;
tool_id: string;
work_url: string;
note: string;
name: string;
start_url: string;
is_active: number;
}[]
).map((tool) => ({
id: tool.id,
toolId: tool.tool_id,
workUrl: tool.work_url,
note: tool.note,
name: tool.name,
startUrl: tool.start_url,
isActive: tool.is_active === 1
})) }))
}; };
} }
@@ -473,6 +534,21 @@ function replaceRelations(pieceId: string, input: PieceInput) {
input.sources.forEach((source, position) => input.sources.forEach((source, position) =>
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position) addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position)
); );
database.prepare('DELETE FROM piece_tools WHERE piece_id = ?').run(pieceId);
const addTool = database.prepare(
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
VALUES (?, ?, ?, ?, ?, ?)`
);
(input.tools ?? []).forEach((tool, position) =>
addTool.run(randomUUID(), pieceId, tool.toolId, tool.workUrl, tool.note, position)
);
}
export function setPieceFavorite(id: string, favorite: boolean) {
const result = getDatabase()
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?')
.run(favorite ? 1 : 0, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
} }
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) { export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
@@ -484,7 +560,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => { const save = database.transaction(() => {
const result = database const result = database
.prepare( .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 = ?, intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
@@ -496,6 +572,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
) )
.run( .run(
input.title, input.title,
input.outputTitle,
input.summary, input.summary,
input.formatId, input.formatId,
input.status, input.status,
@@ -531,50 +608,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
return { revision: expectedRevision + 1, updatedAtUtc }; return { revision: expectedRevision + 1, updatedAtUtc };
} }
function templateDefaults(template: PieceTemplateId): Partial<PieceInput> & { title: string } { function blankPieceInput(): PieceInput {
if (template === 'talk') { return {
return { title: 'Neues Piece',
title: 'Unbenanntes Gespräch', outputTitle: '',
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,
summary: '', summary: '',
formatId: defaults.formatId ?? null, formatId: null,
status: 'preparing', status: 'preparing',
durationSeconds: 0, durationSeconds: 0,
intro: '', intro: '',
researchText: '', researchText: '',
moderationNotes: defaults.moderationNotes ?? '', moderationNotes: '',
transitionText: '', transitionText: '',
presentationText: defaults.presentationText ?? '', presentationText: '',
presentationMode: 'text', presentationMode: 'text',
presentationSourcePosition: null, presentationSourcePosition: null,
presentationTitle: '', presentationTitle: '',
@@ -588,20 +634,63 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
presentationImageManual: false, presentationImageManual: false,
presentationMetadataFetchedAtUtc: null, presentationMetadataFetchedAtUtc: null,
topicIds: [], topicIds: [],
categoryIds: defaults.categoryIds ?? [], categoryIds: [],
sources: [] 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(); const database = getDatabase();
database.transaction(() => { database.transaction(() => {
database database
.prepare( .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, research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
input.title, input.title,
input.outputTitle,
input.summary, input.summary,
input.formatId, input.formatId,
input.status, input.status,
@@ -619,12 +708,68 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
return id; 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 { export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title'); let title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
const url = cleanText(urlValue, 2_000, 'source'); const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES'); 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)!; const piece = getPiece(id)!;
savePiece(id, piece.revision, { savePiece(id, piece.revision, {
...piece, ...piece,
@@ -691,18 +836,19 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => { database.transaction(() => {
database database
.prepare( .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, research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual, presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
`Kopie von ${source.title}`.slice(0, 200), `Kopie von ${source.title}`.slice(0, 200),
source.outputTitle,
source.summary, source.summary,
source.formatId, source.formatId,
source.durationSeconds, source.durationSeconds,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 6, migrations: 9,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+117 -3
View File
@@ -6,13 +6,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js'; import { closeDatabase, getDatabase } from './database.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel,
getPresentationChannelView,
getLiveView, getLiveView,
getPresentationMedia, getPresentationMedia,
getPresentationView, getPresentationView,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
presentationChannelAccess,
presentUsage, presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent, skipCurrent,
startOrResumeLive startOrResumeLive
} from './live.js'; } from './live.js';
@@ -35,6 +40,99 @@ afterEach(() => {
}); });
describe('live mode', () => { describe('live mode', () => {
it('persists a frozen section countdown across usage changes and reloads', () => {
getDatabase()
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
)
.run();
const start = new Date('2026-10-23T18:00:00.000Z');
startOrResumeLive('show-october', 'timer-start', 0, start);
let live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
sectionId: 'section-stories',
plannedDurationSeconds: 300,
elapsedSeconds: 100
});
getDatabase()
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
)
.run();
completeCurrent(
'show-october',
'timer-next-piece',
live.liveRevision,
new Date('2026-10-23T18:02:00.000Z')
);
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
expect(live.current?.id).toBe('usage-effects');
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 300,
elapsedSeconds: 240
});
completeCurrent(
'show-october',
'timer-finish',
live.liveRevision,
new Date('2026-10-23T18:06:00.000Z')
);
expect(
getLiveView('show-october', new Date('2026-10-23T18:07:00.000Z')).sectionTimer
).toBeNull();
});
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', () => { it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z'); const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started); const initial = getLiveView('show-october', started);
@@ -105,6 +203,14 @@ describe('live mode', () => {
0, 0,
'2026-10-22T18:00:00.000Z' '2026-10-22T18:00:00.000Z'
); );
getDatabase()
.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES ('private-live-tool', 'usage-console', NULL, 'Privates Werkzeug', '',
'https://private.example.test/board', 'PRIVATE_TOOL_NOTE', 0)`
)
.run();
const token = issuePresentationAccess('show-october'); const token = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toEqual({ revision: 0, content: null }); expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
@@ -115,7 +221,9 @@ describe('live mode', () => {
const output = getPresentationView(token)!; const output = getPresentationView(token)!;
expect(output.content).toMatchObject({ expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh', title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }] topics: ['Gaming'],
images: [],
sources: []
}); });
const serialized = JSON.stringify(output); const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.'); expect(serialized).toContain('Weniger Leistung, mehr Idee.');
@@ -123,13 +231,19 @@ describe('live mode', () => {
expect(serialized).not.toContain('Moderationsstichpunkte'); expect(serialized).not.toContain('Moderationsstichpunkte');
expect(serialized).not.toContain('private-original-name'); expect(serialized).not.toContain('private-original-name');
expect(serialized).not.toContain('presentation.png'); expect(serialized).not.toContain('presentation.png');
expect(serialized).not.toContain('https://example.com/console');
expect(serialized).not.toContain('https://private.example.test/board');
expect(serialized).not.toContain('PRIVATE_TOOL_NOTE');
const media = await getPresentationMedia(token, 'presentation-image'); const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image'); expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull(); expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october'); 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( expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh' 'Die kleine Konsole mit dem großen Dreh'
); );
+244 -58
View File
@@ -1,6 +1,5 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js'; import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -63,12 +62,20 @@ export type LiveView = {
next: LiveUsage | null; next: LiveUsage | null;
completedCount: number; completedCount: number;
totalCount: number; totalCount: number;
sectionTimer: null | {
sectionId: string;
sectionName: string;
plannedDurationSeconds: number;
elapsedSeconds: number;
runningSinceUtc: string | null;
};
}; };
export type PresentationView = { export type PresentationView = {
revision: number; revision: number;
content: null | { content: null | {
mode: PresentationContent['mode']; mode: PresentationContent['mode'];
topics: string[];
title: string; title: string;
html: string; html: string;
source: PresentationContent['source']; source: PresentationContent['source'];
@@ -142,6 +149,59 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
.run(now, showId); .run(now, showId);
} }
function closeSectionTimer(database: Database.Database, showId: string, now: string) {
const timer = database
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId) as { id: string } | undefined;
if (!timer) return;
database
.prepare(
'UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id = ? AND ended_at_utc IS NULL'
)
.run(now, timer.id);
database
.prepare('UPDATE live_section_timers SET ended_at_utc = ? WHERE id = ?')
.run(now, timer.id);
}
function syncSectionTimer(
database: Database.Database,
showId: string,
usageId: string,
now: string
) {
const section = database
.prepare(
`SELECT s.id, s.explicit_duration_seconds FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(usageId, showId) as { id: string; explicit_duration_seconds: number | null } | undefined;
if (!section) throw new Error('NOT_FOUND');
const active = database
.prepare(
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL'
)
.get(showId) as { id: string; section_id: string } | undefined;
if (active?.section_id === section.id) return;
if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const timerId = randomUUID();
database
.prepare(
`INSERT INTO live_section_timers
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timerId, now);
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) { function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
usageBelongsToShow(database, showId, usageId); usageBelongsToShow(database, showId, usageId);
database database
@@ -165,6 +225,7 @@ function activateUsage(database: Database.Database, showId: string, usageId: str
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?' 'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
) )
.run(usageId, showId); .run(usageId, showId);
syncSectionTimer(database, showId, usageId, now);
} }
function executeCommand( function executeCommand(
@@ -269,6 +330,44 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
const next = const next =
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ?? usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null)); (current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
const timer = database
.prepare(
`SELECT t.id, t.section_id, s.name AS section_name, t.planned_duration_seconds
FROM live_section_timers t JOIN show_sections s ON s.id = t.section_id
WHERE t.show_id = ? AND t.ended_at_utc IS NULL`
)
.get(showId) as
| { id: string; section_id: string; section_name: string; planned_duration_seconds: number }
| undefined;
let sectionTimer: LiveView['sectionTimer'] = null;
if (timer) {
const runs = database
.prepare(
`SELECT started_at_utc, ended_at_utc FROM live_section_timer_runs
WHERE timer_id = ? ORDER BY started_at_utc`
)
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
const elapsedSeconds = runs.reduce(
(total, run) =>
total +
Math.max(
0,
Math.floor(
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
1_000
)
),
0
);
sectionTimer = {
sectionId: timer.section_id,
sectionName: timer.section_name,
plannedDurationSeconds: timer.planned_duration_seconds,
elapsedSeconds,
runningSinceUtc: running?.started_at_utc ?? null
};
}
return { return {
show: { show: {
id: show.id, id: show.id,
@@ -288,7 +387,8 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
next, next,
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped') completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
.length, .length,
totalCount: usages.length totalCount: usages.length,
sectionTimer
}; };
} }
@@ -354,10 +454,12 @@ function advanceCurrent(
.run(status, state.current_usage_id); .run(status, state.current_usage_id);
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open'); const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
if (next) activateUsage(database, showId, next.id, now); if (next) activateUsage(database, showId, next.id, now);
else else {
closeSectionTimer(database, showId, now);
database database
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?') .prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
.run(showId); .run(showId);
}
}, },
nowValue nowValue
); );
@@ -414,6 +516,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( export function presentUsage(
showId: string, showId: string,
usageId: string | null, usageId: string | null,
@@ -480,25 +610,90 @@ export function issuePresentationAccess(showId: string) {
const database = getDatabase(); const database = getDatabase();
const now = nowIso(); const now = nowIso();
ensureLiveState(database, showId, now); ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url'); return database.transaction(() => {
database.transaction(() => { let channel = database
database .prepare("SELECT * FROM presentation_channels WHERE id = 'default'")
.prepare( .get() as { access_key: string; show_id: string | null } | undefined;
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL' if (!channel) {
) const accessKey = randomBytes(32).toString('base64url');
.run(now, showId); clearPresentationState(database, showId);
database database
.prepare( .prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc) `INSERT INTO presentation_channels
VALUES (?, ?, ?, ?)` (id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc)
) VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)`
.run(randomUUID(), showId, hashToken(token), now); )
.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(
`UPDATE presentation_channels SET show_id = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.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) { function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null; 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 const row = database
.prepare( .prepare(
`SELECT show_id FROM presentation_access_tokens `SELECT show_id FROM presentation_access_tokens
@@ -508,15 +703,30 @@ function presentationShowId(database: Database.Database, token: string) {
return row?.show_id ?? null; 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 { export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase(); const database = getDatabase();
const showId = presentationShowId(database, token); const showId = presentationShowId(database, token);
if (!showId) return null; if (!showId) return null;
return getPresentationViewForShow(showId);
}
function getPresentationViewForShow(showId: string): PresentationView {
const database = getDatabase();
const state = ensureLiveState(database, showId, nowIso()); const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database const usage = database
.prepare( .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_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -528,6 +738,7 @@ export function getPresentationView(token: string): PresentationView | null {
.get(state.presentation_usage_id, showId) as .get(state.presentation_usage_id, showId) as
| { | {
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
original_piece_id: string | null; original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode']; presentation_mode_snapshot: PresentationContent['mode'];
@@ -543,33 +754,16 @@ export function getPresentationView(token: string): PresentationView | null {
} }
| undefined; | undefined;
if (!usage) return { revision: state.presentation_revision, content: null }; 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 { return {
revision: state.presentation_revision, revision: state.presentation_revision,
content: { content: {
mode: usage.presentation_mode_snapshot, mode: usage.presentation_mode_snapshot,
title: usage.title_snapshot, topics: (
database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
.all(state.presentation_usage_id) as { name: string }[]
).map((topic) => topic.name),
title: usage.output_title_snapshot || usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot), html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot source: usage.presentation_source_url_snapshot
? { ? {
@@ -589,8 +783,8 @@ export function getPresentationView(token: string): PresentationView | null {
usage.presentation_provider_id_snapshot, usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources, sources: [],
images images: []
} }
}; };
} }
@@ -605,19 +799,11 @@ export async function getPresentationPreviewImage(token: string) {
} }
} }
export async function getPresentationMedia(token: string, mediaId: string) { export async function getPresentationMedia(
const database = getDatabase(); token: string,
const showId = presentationShowId(database, token); mediaId: string
if (!showId) return null; ): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
const allowed = database void token;
.prepare( void mediaId;
`SELECT 1 FROM show_live_states l return null;
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);
} }
+1
View File
@@ -28,6 +28,7 @@ export type PresentationSource = {
export type PresentationContent = { export type PresentationContent = {
mode: PresentationMode; mode: PresentationMode;
topics: string[];
title: string; title: string;
html: string; html: string;
source: PresentationSource | null; source: PresentationSource | null;
+129 -1
View File
@@ -9,6 +9,7 @@ import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createShow, createShow,
createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
getPieceUsageContexts, getPieceUsageContexts,
getShow, getShow,
@@ -44,6 +45,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
const edited = { ...usage, ...overrides }; const edited = { ...usage, ...overrides };
return { return {
title: edited.title, title: edited.title,
outputTitle: edited.outputTitle,
intro: edited.intro, intro: edited.intro,
research: edited.research, research: edited.research,
moderation: edited.moderation, moderation: edited.moderation,
@@ -63,6 +65,64 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
} }
describe('show builder data layer', () => { 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', () => { it('creates blank and templated shows with independent sections and reserve banks', () => {
const templatesBefore = listShowTemplates(); const templatesBefore = listShowTemplates();
expect( expect(
@@ -360,7 +420,8 @@ describe('show builder data layer', () => {
.run('https://youtu.be/dQw4w9WgXcQ'); .run('https://youtu.be/dQw4w9WgXcQ');
getDatabase() getDatabase()
.prepare( .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_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube', presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'` 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]; let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({ expect(usage).toMatchObject({
outputTitle: 'Öffentlicher Ausgabetitel',
presentationMode: 'video', presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ', presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75, presentationSourceTimecodeSeconds: 75,
@@ -402,6 +464,72 @@ describe('show builder data layer', () => {
}); });
}); });
it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => {
const database = getDatabase();
database
.prepare(
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)`
)
.run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z');
database
.prepare(
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)`
)
.run();
const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Block',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
topics: ['Gaming']
});
database
.prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'")
.run();
database
.prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'")
.run();
database
.prepare(
"UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'"
)
.run();
database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run();
database
.prepare(
"INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')"
)
.run();
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.sources[0].label).toBe('Herstellerseite');
expect(usage.tools[0]).toMatchObject({
name: 'Tierlist',
workUrl: 'https://example.com/board'
});
expect(usage.topics).toEqual(['Gaming']);
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.sources[0].label).toBe('Neue Quelle');
expect(usage.tools[0]).toMatchObject({
name: 'Neuer Katalogname',
workUrl: 'https://example.com/new'
});
expect(usage.topics).toEqual(['Netzkultur']);
});
it('persists normalized section and usage reordering, moving and removal', () => { it('persists normalized section and usage reordering, moving and removal', () => {
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null }); const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
const firstSection = addShowSection(created.id, created.revision, { const firstSection = addShowSection(created.id, created.revision, {
+309 -13
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createPiece } from './content.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js'; import { detectVideoProvider, type PresentationMode } from './presentation.js';
@@ -42,6 +43,7 @@ export type PieceUsage = {
status: UsageStatus; status: UsageStatus;
durationSeconds: number; durationSeconds: number;
title: string; title: string;
outputTitle: string;
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
@@ -58,6 +60,16 @@ export type PieceUsage = {
presentationProvider: string; presentationProvider: string;
presentationProviderId: string; presentationProviderId: string;
ownNotes: string; ownNotes: string;
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
tools: {
id: string;
originalToolId: string | null;
name: string;
startUrl: string;
workUrl: string;
note: string;
}[];
topics: string[];
}; };
export type SentPieceUsage = { export type SentPieceUsage = {
@@ -129,6 +141,7 @@ export type PieceUsageInput = {
export type PieceUsageUpdate = { export type PieceUsageUpdate = {
title: string; title: string;
outputTitle: string;
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
@@ -144,6 +157,7 @@ export type PieceUsageUpdate = {
presentationSiteName: string; presentationSiteName: string;
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
tools?: { id: string; workUrl: string; note: string }[];
}; };
export type MovePieceUsageInput = { export type MovePieceUsageInput = {
@@ -185,6 +199,7 @@ type UsageRow = {
status: UsageStatus; status: UsageStatus;
duration_seconds: number; duration_seconds: number;
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string;
intro_snapshot: string; intro_snapshot: string;
research_snapshot: string; research_snapshot: string;
moderation_snapshot: string; moderation_snapshot: string;
@@ -368,7 +383,7 @@ function normalizePositions(
ids.forEach((id, position) => update.run(position, id)); ids.forEach((id, position) => update.run(position, id));
} }
function usageFromRow(row: UsageRow): PieceUsage { function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
return { return {
id: row.id, id: row.id,
originalPieceId: row.original_piece_id, originalPieceId: row.original_piece_id,
@@ -376,6 +391,7 @@ function usageFromRow(row: UsageRow): PieceUsage {
status: row.status, status: row.status,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
title: row.title_snapshot, title: row.title_snapshot,
outputTitle: row.output_title_snapshot,
intro: row.intro_snapshot, intro: row.intro_snapshot,
research: row.research_snapshot, research: row.research_snapshot,
moderation: row.moderation_snapshot, moderation: row.moderation_snapshot,
@@ -391,10 +407,189 @@ function usageFromRow(row: UsageRow): PieceUsage {
presentationSiteName: row.presentation_site_name_snapshot, presentationSiteName: row.presentation_site_name_snapshot,
presentationProvider: row.presentation_provider_snapshot, presentationProvider: row.presentation_provider_snapshot,
presentationProviderId: row.presentation_provider_id_snapshot, presentationProviderId: row.presentation_provider_id_snapshot,
ownNotes: row.own_notes ownNotes: row.own_notes,
sources: (
database
.prepare(
`SELECT id, label, url, timecode_seconds FROM usage_source_snapshots
WHERE usage_id = ? ORDER BY position`
)
.all(row.id) as {
id: string;
label: string;
url: string;
timecode_seconds: number | null;
}[]
).map((source) => ({
id: source.id,
label: source.label,
url: source.url,
timecodeSeconds: source.timecode_seconds
})),
tools: (
database
.prepare(
`SELECT id, original_tool_id, name, start_url, work_url, note
FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position`
)
.all(row.id) as {
id: string;
original_tool_id: string | null;
name: string;
start_url: string;
work_url: string;
note: string;
}[]
).map((tool) => ({
id: tool.id,
originalToolId: tool.original_tool_id,
name: tool.name,
startUrl: tool.start_url,
workUrl: tool.work_url,
note: tool.note
})),
topics: (
database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
.all(row.id) as { name: string }[]
).map((topic) => topic.name)
}; };
} }
function snapshotPieceRelations(database: Database.Database, usageId: string, pieceId: string) {
database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId);
const sources = database
.prepare(
`SELECT label, url, timecode_seconds, position FROM piece_sources
WHERE piece_id = ? ORDER BY position`
)
.all(pieceId) as {
label: string;
url: string;
timecode_seconds: number | null;
position: number;
}[];
const addSource = database.prepare(
`INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position)
VALUES (?, ?, ?, ?, ?, ?)`
);
for (const source of sources) {
addSource.run(
randomUUID(),
usageId,
source.label,
source.url,
source.timecode_seconds,
source.position
);
}
const tools = database
.prepare(
`SELECT pt.tool_id, t.name, t.start_url, pt.work_url, pt.note, pt.position
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
WHERE pt.piece_id = ? ORDER BY pt.position`
)
.all(pieceId) as {
tool_id: string;
name: string;
start_url: string;
work_url: string;
note: string;
position: number;
}[];
const addTool = database.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const tool of tools) {
addTool.run(
randomUUID(),
usageId,
tool.tool_id,
tool.name,
tool.start_url,
tool.work_url,
tool.note,
tool.position
);
}
const topics = database
.prepare(
`SELECT t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name, t.id`
)
.all(pieceId) as { name: string }[];
const addTopic = database.prepare(
'INSERT INTO usage_topic_snapshots (usage_id, name, position) VALUES (?, ?, ?)'
);
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, position));
}
function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) {
const sources = database
.prepare(
`SELECT label, url, timecode_seconds, position FROM usage_source_snapshots
WHERE usage_id = ? ORDER BY position`
)
.all(sourceUsageId) as {
label: string;
url: string;
timecode_seconds: number | null;
position: number;
}[];
const addSource = database.prepare(
`INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position)
VALUES (?, ?, ?, ?, ?, ?)`
);
for (const source of sources)
addSource.run(
randomUUID(),
usageId,
source.label,
source.url,
source.timecode_seconds,
source.position
);
const tools = database
.prepare(
`SELECT original_tool_id, name, start_url, work_url, note, position
FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position`
)
.all(sourceUsageId) as {
original_tool_id: string | null;
name: string;
start_url: string;
work_url: string;
note: string;
position: number;
}[];
const addTool = database.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const tool of tools)
addTool.run(
randomUUID(),
usageId,
tool.original_tool_id,
tool.name,
tool.start_url,
tool.work_url,
tool.note,
tool.position
);
database
.prepare(
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
SELECT ?, name, position FROM usage_topic_snapshots WHERE usage_id = ?`
)
.run(usageId, sourceUsageId);
}
export function listShows(): ShowListItem[] { export function listShows(): ShowListItem[] {
return ( return (
getDatabase() getDatabase()
@@ -478,7 +673,7 @@ export function getShow(id: string): Show | null {
const usageRows = database const usageRows = database
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `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.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
@@ -492,7 +687,7 @@ export function getShow(id: string): Show | null {
const usagesBySection = new Map<string, PieceUsage[]>(); const usagesBySection = new Map<string, PieceUsage[]>();
for (const row of usageRows) { for (const row of usageRows) {
const usages = usagesBySection.get(row.show_section_id) ?? []; const usages = usagesBySection.get(row.show_section_id) ?? [];
usages.push(usageFromRow(row)); usages.push(usageFromRow(database, row));
usagesBySection.set(row.show_section_id, usages); usagesBySection.set(row.show_section_id, usages);
} }
const sections = sectionRows const sections = sectionRows
@@ -805,7 +1000,7 @@ export function addPieceUsage(
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null); sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database const piece = database
.prepare( .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, p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
@@ -818,6 +1013,7 @@ export function addPieceUsage(
.get(input.pieceId) as .get(input.pieceId) as
| { | {
title: string; title: string;
output_title: string;
duration_seconds: number; duration_seconds: number;
intro: string; intro: string;
research_text: string; research_text: string;
@@ -849,14 +1045,14 @@ export function addPieceUsage(
database database
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `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, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -865,6 +1061,7 @@ export function addPieceUsage(
ids.length, ids.length,
piece.duration_seconds, piece.duration_seconds,
piece.title, piece.title,
piece.output_title,
piece.intro, piece.intro,
piece.research_text, piece.research_text,
piece.moderation_notes, piece.moderation_notes,
@@ -882,12 +1079,67 @@ export function addPieceUsage(
piece.presentation_provider_id, piece.presentation_provider_id,
input.sectionId === null ? 1 : 0 input.sectionId === null ? 1 : 0
); );
snapshotPieceRelations(database, id, input.pieceId);
ids.splice(position, 0, id); ids.splice(position, 0, id);
normalizePositions(database, 'piece_usages', ids); normalizePositions(database, 'piece_usages', ids);
return { ...result, id }; return { ...result, id };
})(); })();
} }
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( export function duplicatePieceUsage(
showId: string, showId: string,
usageId: string, usageId: string,
@@ -899,7 +1151,7 @@ export function duplicatePieceUsage(
const sourceSection = usageSection(database, showId, usageId); const sourceSection = usageSection(database, showId, usageId);
const source = database const source = database
.prepare( .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, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
@@ -922,14 +1174,14 @@ export function duplicatePieceUsage(
database database
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `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, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -938,6 +1190,7 @@ export function duplicatePieceUsage(
ids.length, ids.length,
source.duration_seconds, source.duration_seconds,
source.title_snapshot, source.title_snapshot,
source.output_title_snapshot,
source.intro_snapshot, source.intro_snapshot,
source.research_snapshot, source.research_snapshot,
source.moderation_snapshot, source.moderation_snapshot,
@@ -956,6 +1209,7 @@ export function duplicatePieceUsage(
source.own_notes, source.own_notes,
source.is_reserve source.is_reserve
); );
copyUsageRelations(database, usageId, id);
ids.splice(sourcePosition + 1, 0, id); ids.splice(sourcePosition + 1, 0, id);
normalizePositions(database, 'piece_usages', ids); normalizePositions(database, 'piece_usages', ids);
return { ...result, id }; return { ...result, id };
@@ -973,7 +1227,7 @@ export function refreshPieceUsageFromOriginal(
usageSection(database, showId, usageId); usageSection(database, showId, usageId);
const source = database const source = database
.prepare( .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, p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
@@ -988,6 +1242,7 @@ export function refreshPieceUsageFromOriginal(
.get(usageId) as .get(usageId) as
| { | {
title: string; title: string;
output_title: string;
duration_seconds: number; duration_seconds: number;
intro: string; intro: string;
research_text: string; research_text: string;
@@ -1009,7 +1264,7 @@ export function refreshPieceUsageFromOriginal(
if (!source) throw new Error('ORIGINAL_NOT_FOUND'); if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database database
.prepare( .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 = ?, research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
@@ -1020,6 +1275,7 @@ export function refreshPieceUsageFromOriginal(
) )
.run( .run(
source.title, source.title,
source.output_title,
source.duration_seconds, source.duration_seconds,
source.intro, source.intro,
source.research_text, source.research_text,
@@ -1038,6 +1294,15 @@ export function refreshPieceUsageFromOriginal(
source.presentation_provider_id, source.presentation_provider_id,
usageId usageId
); );
snapshotPieceRelations(
database,
usageId,
(
database
.prepare('SELECT original_piece_id FROM piece_usages WHERE id = ?')
.get(usageId) as { original_piece_id: string }
).original_piece_id
);
return result; return result;
})(); })();
} }
@@ -1049,6 +1314,7 @@ export function updatePieceUsage(
input: PieceUsageUpdate input: PieceUsageUpdate
): MutationResult { ): MutationResult {
const title = cleanText(input.title, 200, 'title', true); const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!; const duration = validDuration(input.durationSeconds)!;
const values = { const values = {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
@@ -1074,7 +1340,7 @@ export function updatePieceUsage(
usageSection(database, showId, usageId); usageSection(database, showId, usageId);
database database
.prepare( .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 = ?, moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
@@ -1085,6 +1351,7 @@ export function updatePieceUsage(
) )
.run( .run(
title, title,
outputTitle,
values.intro, values.intro,
values.research, values.research,
values.moderation, values.moderation,
@@ -1104,6 +1371,35 @@ export function updatePieceUsage(
duration, duration,
usageId usageId
); );
if (input.tools) {
const existing = database
.prepare('SELECT id FROM usage_tool_snapshots WHERE usage_id = ?')
.all(usageId) as { id: string }[];
const existingIds = new Set(existing.map((tool) => tool.id));
if (
input.tools.length !== existing.length ||
input.tools.some((tool) => !existingIds.has(tool.id))
) {
throw new Error('INVALID_TOOLS');
}
const updateTool = database.prepare(
'UPDATE usage_tool_snapshots SET work_url = ?, note = ? WHERE id = ? AND usage_id = ?'
);
for (const tool of input.tools) {
const workUrl = cleanText(tool.workUrl, 2_000, 'tools');
if (workUrl) {
try {
const url = new URL(workUrl);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error();
}
} catch {
throw new Error('INVALID_TOOLS');
}
}
updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId);
}
}
return result; return result;
})(); })();
} }
+64
View File
@@ -0,0 +1,64 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase } from './database.js';
import { createTool, listTools, updateTool } from './tools.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-tools-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('tool catalog', () => {
it('validates, classifies and deactivates tools without deleting them', () => {
const id = createTool({
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
isActive: true,
formatIds: ['format-quiz']
});
expect(listTools()).toEqual([
{
id,
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
isActive: true,
formatIds: ['format-quiz']
}
]);
updateTool(id, {
name: 'Tierlist',
startUrl: '',
description: '',
isActive: false,
formatIds: []
});
expect(listTools()).toEqual([]);
expect(listTools(true)[0]).toMatchObject({ id, isActive: false });
expect(() =>
createTool({
name: 'Unsicher',
startUrl: 'javascript:alert(1)',
description: '',
isActive: true,
formatIds: []
})
).toThrow('INVALID_TOOL_URL');
});
});
+137
View File
@@ -0,0 +1,137 @@
import { randomUUID } from 'node:crypto';
import { getDatabase } from './database.js';
export type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
isActive: boolean;
formatIds: string[];
};
export type ToolInput = Omit<Tool, 'id'>;
function clean(value: unknown, maximum: number, required = false) {
const text = String(value ?? '').trim();
if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL');
return text;
}
function validUrl(value: string) {
if (!value) return '';
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
throw new Error();
return url.href;
} catch {
throw new Error('INVALID_TOOL_URL');
}
}
function validated(input: ToolInput): ToolInput {
const formatIds = [...new Set(input.formatIds.filter(Boolean))];
if (formatIds.length > 30) throw new Error('INVALID_TOOL');
if (formatIds.length) {
const placeholders = formatIds.map(() => '?').join(', ');
const row = getDatabase()
.prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`)
.get(...formatIds) as { count: number };
if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT');
}
return {
name: clean(input.name, 120, true),
startUrl: validUrl(clean(input.startUrl, 2_000)),
description: clean(input.description, 2_000),
isActive: Boolean(input.isActive),
formatIds
};
}
export function listTools(includeInactive = false): Tool[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT id, name, start_url, description, is_active FROM tools
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
)
.all() as {
id: string;
name: string;
start_url: string;
description: string;
is_active: number;
}[];
const formats = database.prepare(
'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id'
);
return rows.map((row) => ({
id: row.id,
name: row.name,
startUrl: row.start_url,
description: row.description,
isActive: row.is_active === 1,
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
}));
}
function replaceFormats(toolId: string, formatIds: string[]) {
const database = getDatabase();
database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId);
const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)');
for (const formatId of formatIds) insert.run(toolId, formatId);
}
export function createTool(rawInput: ToolInput) {
const input = validated(rawInput);
const database = getDatabase();
const id = randomUUID();
const now = new Date().toISOString();
try {
database.transaction(() => {
database
.prepare(
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(id, input.name, input.startUrl, input.description, input.isActive ? 1 : 0, now, now);
replaceFormats(id, input.formatIds);
})();
} catch (cause) {
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
throw new Error('TOOL_EXISTS', { cause });
}
throw cause;
}
return id;
}
export function updateTool(id: string, rawInput: ToolInput) {
const input = validated(rawInput);
const database = getDatabase();
try {
database.transaction(() => {
const result = database
.prepare(
`UPDATE tools SET name = ?, start_url = ?, description = ?, is_active = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(
input.name,
input.startUrl,
input.description,
input.isActive ? 1 : 0,
new Date().toISOString(),
id
);
if (result.changes !== 1) throw new Error('NOT_FOUND');
replaceFormats(id, input.formatIds);
})();
} catch (cause) {
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
throw new Error('TOOL_EXISTS', { cause });
}
throw cause;
}
}
+3 -4
View File
@@ -1,4 +1,4 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } from '#lib/server/content.js'; import { captureInbox, listPieces } from '#lib/server/content.js';
@@ -12,9 +12,9 @@ export const actions = {
capture: async ({ locals, request }) => { capture: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string;
try { try {
id = captureInbox(String(data.get('title') || ''), String(data.get('url') || '')); const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
return { captured: true, capturedId: id };
} catch (cause) { } catch (cause) {
const code = cause instanceof Error ? cause.message : ''; const code = cause instanceof Error ? cause.message : '';
return fail(400, { return fail(400, {
@@ -26,6 +26,5 @@ export const actions = {
: 'Ein Titel ist erforderlich.' : 'Ein Titel ist erforderlich.'
}); });
} }
redirect(303, `/fundus/${id}`);
} }
} satisfies Actions; } satisfies Actions;
+6 -3
View File
@@ -17,11 +17,14 @@
<h2>Was soll nicht verloren gehen?</h2> <h2>Was soll nicht verloren gehen?</h2>
</div> </div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#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"> <form method="POST" action="?/capture" class="capture-form">
<label <label
>Titel oder Gedanke<input >Gedanke <span>optional bei URL</span><input
name="title" name="title"
required
maxlength="200" maxlength="200"
value={form?.title ?? ''} value={form?.title ?? ''}
placeholder="Kurze Idee festhalten" placeholder="Kurze Idee festhalten"
@@ -36,7 +39,7 @@
placeholder="https://…" placeholder="https://…"
/></label /></label
> >
<button class="primary-button" type="submit">Aufnehmen und öffnen</button> <button class="primary-button" type="submit">Im Eingang ablegen</button>
</form> </form>
</section> </section>
{#if data.pieces.length} {#if data.pieces.length}
+25 -4
View File
@@ -3,12 +3,13 @@ import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { import {
createPiece, createPiece,
deletePieceTemplate,
duplicatePiece, duplicatePiece,
getTaxonomy, getTaxonomy,
listPieces, listPieces,
pieceTemplates, listPieceTemplates,
setPieceArchived, setPieceArchived,
type PieceTemplateId setPieceFavorite
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js'; import { getPieceUsageContexts } from '#lib/server/shows.js';
@@ -39,7 +40,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
return { return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
templates: pieceTemplates, templates: listPieceTemplates(),
filters filters
}; };
}; };
@@ -57,12 +58,22 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
let id: string; let id: string;
try { try {
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId); id = createPiece(String(data.get('template') || 'blank'));
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) }); return fail(400, { intent: 'create', error: actionError(cause) });
} }
redirect(303, `/fundus/${id}`); 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 }) => { duplicate: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -83,5 +94,15 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(409, { intent: 'archive', error: actionError(cause) }); return fail(409, { intent: 'archive', error: actionError(cause) });
} }
},
favorite: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceFavorite(String(data.get('pieceId') || ''), data.get('favorite') === '1');
return { intent: 'favorite', success: 'Favorit aktualisiert.' };
} catch (cause) {
return fail(404, { intent: 'favorite', error: actionError(cause) });
}
} }
} satisfies Actions; } satisfies Actions;
+155 -103
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import { goto, replaceState } from '$app/navigation'; import { goto, replaceState } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -13,7 +14,7 @@
let visiblePieces = $derived( let visiblePieces = $derived(
data.pieces.filter( data.pieces.filter(
(piece) => (piece) =>
(showUsedPieces || piece.sentHistory.length === 0) && (showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
(showPlannedPieces || !piece.isPlannedInActiveShow) (showPlannedPieces || !piece.isPlannedInActiveShow)
) )
); );
@@ -59,101 +60,132 @@
<h1>Fundus</h1> <h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div> </div>
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}> <div class="fundus-heading-actions">
<strong>{visiblePieces.length}</strong><span>Inhalte</span> <div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div>
<details class="template-picker">
<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">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button
>
</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> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#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} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<details class="template-picker">
<summary>Neues Piece anlegen</summary>
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button
>
</form>
{/each}
</div>
</details>
<form <form
id="library-filters" id="library-filters"
class="library-filters" class="library-filters"
method="GET" method="GET"
aria-label="Fundus durchsuchen und filtern" aria-label="Fundus durchsuchen und filtern"
> >
<label class="search-field" <div class="fundus-search-row">
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input <label class="search-field"
name="q" ><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
type="search" name="q"
value={data.filters.query} type="search"
placeholder="Titel, Quelle oder Thema suchen" value={data.filters.query}
/></label placeholder="Titel, Quelle oder Thema suchen"
> /></label
<label >
><span>Status</span><select name="status" <button class="secondary-button" type="submit">Suchen</button>
><option value="">Alle</option><option <details class="filter-picker">
value="ready" <summary><span aria-hidden="true">≡</span> Weitere Filter</summary>
selected={data.filters.status === 'ready'}>Sendefertig</option <div class="filter-panel">
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option <label
></select ><span>Status</span><select name="status"
></label ><option value="">Alle</option><option
> value="ready"
<label selected={data.filters.status === 'ready'}>Sendefertig</option
><span>Format</span><select name="format" ><option value="preparing" selected={data.filters.status === 'preparing'}
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option >In Arbeit</option
value={item.id} ></select
selected={data.filters.formatId === item.id}>{item.name}</option ></label
>{/each}</select >
></label <label
> ><span>Format</span><select name="format"
<label ><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
><span>Thema</span><select name="topic" value={item.id}
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option selected={data.filters.formatId === item.id}>{item.name}</option
value={item.id} >{/each}</select
selected={data.filters.topicId === item.id}>{item.name}</option ></label
>{/each}</select >
></label <label
> ><span>Thema</span><select name="topic"
<label ><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
><span>Abschnitt</span><select name="category" value={item.id}
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option selected={data.filters.topicId === item.id}>{item.name}</option
value={item.id} >{/each}</select
selected={data.filters.categoryId === item.id}>{item.name}</option ></label
>{/each}</select >
></label <label
> ><span>Abschnitt</span><select name="category"
<button class="secondary-button" type="submit">Anwenden</button> ><option value="">Alle</option
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button> >{#each data.taxonomy.categories as item (item.id)}<option
value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select
></label
>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
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> </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)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
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>
{#if visiblePieces.length} {#if visiblePieces.length}
<div class="piece-grid"> <div class="piece-grid">
@@ -162,13 +194,29 @@
class="piece-card" class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`} style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
> >
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
<input type="hidden" name="pieceId" value={piece.id} />
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
<button
class="piece-favorite"
class:active={piece.isFavorite}
type="submit"
aria-label={piece.isFavorite
? `${piece.title} aus Favoriten entfernen`
: `${piece.title} als Favorit markieren`}
aria-pressed={piece.isFavorite}>★</button
>
</form>
{#if piece.mediaId}<img {#if piece.mediaId}<img
class="piece-thumbnail" class="piece-thumbnail"
src={`/media/${piece.mediaId}`} src={`/media/${piece.mediaId}`}
alt="" alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true" />{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
>S</span <svg viewBox="0 0 24 24" fill="none">
>{/if} <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"> <div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2> <h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p> <p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
@@ -193,22 +241,26 @@
></span ></span
> >
</div> </div>
<div class="piece-actions"> <details class="piece-actions">
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a> <summary aria-label={`Aktionen für ${piece.title}`}
<form method="POST" action="?/duplicate"> ><span aria-hidden="true">•••</span></summary
<input type="hidden" name="pieceId" value={piece.id} /><button >
class="text-button" <div class="piece-actions-popover">
type="submit">Duplizieren</button <form method="POST" action="?/duplicate">
> <input type="hidden" name="pieceId" value={piece.id} /><button
</form> class="text-button"
<form method="POST" action="?/archive"> type="submit">Duplizieren</button
<input type="hidden" name="pieceId" value={piece.id} /><input >
type="hidden" </form>
name="revision" <form method="POST" action="?/archive">
value={piece.revision} <input type="hidden" name="pieceId" value={piece.id} /><input
/><button class="text-button danger-text" type="submit">Archivieren</button> type="hidden"
</form> name="revision"
</div> value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
</div>
</details>
</article> </article>
{/each} {/each}
</div> </div>
+50 -2
View File
@@ -6,15 +6,19 @@ import {
duplicatePiece, duplicatePiece,
getPiece, getPiece,
getTaxonomy, getTaxonomy,
listPieceTemplates,
loadPiecePresentationMetadata, loadPiecePresentationMetadata,
savePiece, savePiece,
savePieceAsTemplate,
setPieceArchived, setPieceArchived,
setPieceFavorite,
uploadPieceImage, uploadPieceImage,
type EditablePieceStatus, type EditablePieceStatus,
type PieceInput, type PieceInput,
type PieceSourceInput type PieceSourceInput
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js'; import { renderMarkdown } from '#lib/server/markdown.js';
import { listTools } from '#lib/server/tools.js';
function renderedFields( function renderedFields(
piece: Pick< piece: Pick<
@@ -55,8 +59,12 @@ function pieceInput(data: FormData): PieceInput {
timecodeSeconds: timecode ? Number(timecode) : null timecodeSeconds: timecode ? Number(timecode) : null
}); });
} }
const toolIds = data.getAll('toolId').map(String);
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
const toolNotes = data.getAll('toolNote').map(String);
return { return {
title: text(data, 'title'), title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
summary: text(data, 'summary'), summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null, formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus, status: text(data, 'status') as EditablePieceStatus,
@@ -82,7 +90,12 @@ function pieceInput(data: FormData): PieceInput {
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null, presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
topicIds: data.getAll('topicIds').map(String), topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String), categoryIds: data.getAll('categoryIds').map(String),
sources sources,
tools: toolIds.map((toolId, index) => ({
toolId,
workUrl: toolWorkUrls[index] ?? '',
note: toolNotes[index] ?? ''
}))
}; };
} }
@@ -120,7 +133,13 @@ export const load: PageServerLoad = ({ locals, params }) => {
const piece = getPiece(params.id); const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden'); if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv'); if (piece.status === 'archived') redirect(303, '/archiv');
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) }; return {
piece,
taxonomy: getTaxonomy(),
tools: listTools(true),
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
rendered: renderedFields(piece)
};
}; };
export const actions = { export const actions = {
@@ -161,6 +180,25 @@ export const actions = {
} }
redirect(303, `/fundus/${params.id}`); 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 }) => { upload: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -216,5 +254,15 @@ export const actions = {
return fail(409, { intent: 'archive', error: saveError(cause).error }); return fail(409, { intent: 'archive', error: saveError(cause).error });
} }
redirect(303, '/archiv'); redirect(303, '/archiv');
},
favorite: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceFavorite(params.id, data.get('favorite') === '1');
return { intent: 'favorite', success: true };
} catch (cause) {
return fail(404, { intent: 'favorite', error: saveError(cause).error });
}
} }
} satisfies Actions; } satisfies Actions;
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { getPiece } from '#lib/server/content.js'; import { getPiece, getTaxonomy } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js'; import { renderMarkdown } from '#lib/server/markdown.js';
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js'; import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
@@ -18,7 +18,10 @@ export const load: PageServerLoad = ({ locals, params }) => {
images: { id: string; altText: string }[]; images: { id: string; altText: string }[];
} = { } = {
mode: piece.presentationMode, mode: piece.presentationMode,
title: piece.title, topics: getTaxonomy()
.topics.filter((topic) => piece.topicIds.includes(topic.id))
.map((topic) => topic.name),
title: piece.outputTitle || piece.title,
html: renderMarkdown(piece.presentationText), html: renderMarkdown(piece.presentationText),
source, source,
previewTitle: piece.presentationTitle, previewTitle: piece.presentationTitle,
+7 -6
View File
@@ -1,9 +1,10 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; 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 }) => { export const load: PageServerLoad = ({ params, cookies }) => {
const view = getPresentationView(params.token); const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!view) error(404, 'Präsentationsausgabe nicht gefunden'); const view = presentationChannelAccess(params.token, accessKey)
return { view }; ? getPresentationChannelView(params.token)
: null;
return { view, channelId: params.token };
}; };
+20 -4
View File
@@ -5,8 +5,10 @@
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let view = $state(untrack(() => data.view)); let view = $state(untrack(() => data.view ?? { revision: -1, content: null }));
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected'); let connection = $state<'connected' | 'waiting' | 'interrupted'>(
untrack(() => (data.view ? 'connected' : 'waiting'))
);
onMount(() => { onMount(() => {
let stopped = false; let stopped = false;
@@ -26,8 +28,22 @@
} }
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000); if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
} }
connection = 'waiting'; async function connect() {
timer = setTimeout(refresh, 500); 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 () => { return () => {
stopped = true; stopped = true;
clearTimeout(timer); 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 { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 }) => { export const GET: RequestHandler = async ({ params, cookies }) => {
const media = await getPresentationMedia(params.token, params.id); 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'); if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, { return new Response(new Uint8Array(media.data), {
headers: { headers: {
'Content-Type': media.mimeType, 'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize), 'Content-Length': String(media.byteSize),
@@ -1,9 +1,12 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 }) => { export const GET: RequestHandler = async ({ params, cookies }) => {
const image = await getPresentationPreviewImage(params.token); 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'); if (!image) error(404, 'Vorschaubild nicht verfügbar');
return new Response(new Uint8Array(image.data), { return new Response(new Uint8Array(image.data), {
headers: { headers: {
@@ -1,9 +1,12 @@
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 }) => { export const GET: RequestHandler = ({ params, cookies }) => {
const view = getPresentationView(params.token); 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'); if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } }); 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 { requireUser } from '#lib/server/auth.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel,
getLiveView, getLiveView,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
presentUsage, presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent, skipCurrent,
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } 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.' 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 === '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 === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED') if (code === 'LIVE_ORDER_LOCKED')
return { return {
@@ -91,6 +96,18 @@ export const actions = {
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) 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 }) => { present: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -118,10 +135,25 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
try { try {
const token = issuePresentationAccess(text(data, 'showId')); 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) { } catch (cause) {
const problem = actionError(cause); const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem }); 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; } satisfies Actions;
+111 -2
View File
@@ -13,6 +13,7 @@
let now = $state(Date.now()); let now = $state(Date.now());
let loadedAt = $state(Date.now()); let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null)); let currentId = $state(untrack(() => data.live.current?.id ?? null));
let loadedRevision = $state(untrack(() => data.live.liveRevision));
let outputWindow: Window | null = null; let outputWindow: Window | null = null;
let progress = $derived( let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0 data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
@@ -21,11 +22,21 @@
(data.live.current?.actualDurationSeconds ?? 0) + (data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0) (data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
); );
let sectionElapsedSeconds = $derived(
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
(data.live.sectionTimer?.runningSinceUtc
? Math.max(0, Math.floor((now - loadedAt) / 1000))
: 0)
);
let sectionRemainingSeconds = $derived(
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
);
$effect(() => { $effect(() => {
const nextCurrentId = data.live.current?.id ?? null; const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId) { if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
currentId = nextCurrentId; currentId = nextCurrentId;
loadedRevision = data.live.liveRevision;
loadedAt = Date.now(); loadedAt = Date.now();
} }
}); });
@@ -73,6 +84,15 @@
persistSettings(); persistSettings();
} }
function formatCountdown(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
return hours > 0
? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
function liveAction(name: string) { function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`; return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
} }
@@ -161,8 +181,20 @@
</form> </form>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}> <form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} /> <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> </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"> <div class="live-session-summary">
<strong>{data.live.completedCount} / {data.live.totalCount}</strong> <strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span> <span>{progress}% bearbeitet</span>
@@ -175,6 +207,21 @@
style={`--prompt-size: ${promptSize}rem`} style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title" aria-labelledby="current-title"
> >
{#if data.live.sectionTimer?.sectionId === data.live.current.sectionId}
<section
class:warning={sectionRemainingSeconds <= 120 && sectionRemainingSeconds >= 0}
class:overrun={sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span>{data.live.sectionTimer.sectionName} · Abschnitt</span>
<strong
>{sectionRemainingSeconds < 0
? `Überzogen +${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: `Noch ${formatCountdown(sectionRemainingSeconds)}`}</strong
>
</section>
{/if}
<div class="live-kicker"> <div class="live-kicker">
<span>Aktuell · {data.live.current.sectionName}</span> <span>Aktuell · {data.live.current.sectionName}</span>
{#if showTimes}<strong {#if showTimes}<strong
@@ -197,6 +244,45 @@
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div> <div class="live-markdown">{@html data.live.current.researchHtml}</div>
</details> </details>
{#if data.live.current.sources.length || data.live.current.tools.length}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<section class="live-materials" aria-labelledby="live-materials-title">
<div>
<span>Nur Redaktion</span>
<h3 id="live-materials-title">Materialien</h3>
</div>
{#if data.live.current.sources.length}<div class="live-material-group">
<strong>Quellen</strong>
<ul>
{#each data.live.current.sources as source (source.id)}<li>
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer"
>{source.label}</a
>{#if source.timecodeSeconds !== null}<small
>ab {formatClock(source.timecodeSeconds)}</small
>{/if}
</li>{/each}
</ul>
</div>{/if}
{#if data.live.current.tools.length}<div class="live-material-group">
<strong>Werkzeuge</strong>
<ul>
{#each data.live.current.tools as tool (tool.id)}<li>
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href={tool.workUrl || tool.startUrl}
target="_blank"
rel="noopener noreferrer">{tool.name}</a
>{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
{#if tool.note}<small>{tool.note}</small>{/if}
</li>{/each}
</ul>
</div>{/if}
</section>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{#if data.live.current.transitionHtml}<section class="transition-card"> {#if data.live.current.transitionHtml}<section class="transition-card">
<span>Überleitung</span> <span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
@@ -221,6 +307,17 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </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}> <form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -288,6 +385,18 @@
<strong>{usage.title}</strong><small>{usage.status}</small> <strong>{usage.title}</strong><small>{usage.status}</small>
</div> </div>
<div class="live-run-actions"> <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'} {#if usage.status === 'open'}
<form <form
method="POST" method="POST"
+26 -2
View File
@@ -1,10 +1,12 @@
import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; 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 { import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
getShow, getShow,
getPieceUsageContexts, getPieceUsageContexts,
@@ -123,6 +125,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
startsAtLocal: berlinInput(show.startsAtUtc), startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
pieceTemplates: listPieceTemplates(),
createCommandId: randomUUID(),
filters: { filters: {
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '', categoryId: url.searchParams.get('category') ?? '',
@@ -197,12 +201,27 @@ 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 }) => { updateUsage: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
const toolIds = data.getAll('usageToolId').map(String);
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
const toolNotes = data.getAll('toolNote').map(String);
return attempt('updateUsage', () => return attempt('updateUsage', () =>
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), { updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
title: text(data, 'title'), title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
durationSeconds: pieceDurationInSeconds(data), durationSeconds: pieceDurationInSeconds(data),
ownNotes: text(data, 'ownNotes'), ownNotes: text(data, 'ownNotes'),
intro: text(data, 'intro'), intro: text(data, 'intro'),
@@ -219,7 +238,12 @@ export const actions = {
presentationTitle: text(data, 'presentationTitle'), presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'), presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'), presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName') presentationSiteName: text(data, 'presentationSiteName'),
tools: toolIds.map((id, index) => ({
id,
workUrl: toolWorkUrls[index] ?? '',
note: toolNotes[index] ?? ''
}))
}) })
); );
}, },
+74 -5
View File
@@ -22,9 +22,11 @@
let moveSectionForm: HTMLFormElement; let moveSectionForm: HTMLFormElement;
let moveUsageForm: HTMLFormElement; let moveUsageForm: HTMLFormElement;
let addUsageForm: HTMLFormElement; let addUsageForm: HTMLFormElement;
let removeUsageForm: HTMLFormElement;
let draggedSectionId = $state(''); let draggedSectionId = $state('');
let draggedUsageId = $state(''); let draggedUsageId = $state('');
let draggedPieceId = $state(''); let draggedPieceId = $state('');
let removedUsageId = $state('');
let dropSectionId = $state(''); let dropSectionId = $state('');
let dropPosition = $state(0); let dropPosition = $state(0);
let copyMessage = $state(''); let copyMessage = $state('');
@@ -33,7 +35,7 @@
let visiblePieces = $derived( let visiblePieces = $derived(
data.pieces.filter( data.pieces.filter(
(piece) => (piece) =>
(showUsedPieces || piece.sentHistory.length === 0) && (showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
(showPlannedPieces || !piece.isPlannedInCurrentShow) (showPlannedPieces || !piece.isPlannedInCurrentShow)
) )
); );
@@ -165,6 +167,17 @@
setTimeout(() => moveUsageForm.requestSubmit()); 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 enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
const affected = formElement.closest<HTMLElement>( const affected = formElement.closest<HTMLElement>(
'.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form' '.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form'
@@ -287,8 +300,18 @@
<div class="builder-workspace"> <div class="builder-workspace">
<section <section
class="builder-pane source-pane" class="builder-pane source-pane"
class:drop-ready={dropTarget === 'source'}
class:mobile-hidden={mobilePane !== 'fundus'} class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title" aria-labelledby="source-title"
ondragover={(event) => {
if (dragged?.kind !== 'usage') return;
event.preventDefault();
dropTarget = 'source';
}}
ondrop={(event) => {
event.preventDefault();
removeDraggedUsage();
}}
> >
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
@@ -297,6 +320,33 @@
</div> </div>
<span>{visiblePieces.length}</span> <span>{visiblePieces.length}</span>
</header> </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"> <form id="builder-filters" method="GET" class="builder-filter">
<label <label
><span class="sr-only">Fundus durchsuchen</span><input ><span class="sr-only">Fundus durchsuchen</span><input
@@ -361,7 +411,10 @@
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span ><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
><span>{formatClock(piece.durationSeconds)}</span> ><span>{formatClock(piece.durationSeconds)}</span>
</div> </div>
<h3>{piece.title}</h3> <h3>
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit">★</span
>{/if}{piece.title}
</h3>
<p> <p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'} {piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
</p> </p>
@@ -533,7 +586,7 @@
ondragend={finishDrag} ondragend={finishDrag}
> >
<strong>{usage.title}</strong><small <strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · sendungsspezifische Kopie</small >{formatClock(usage.durationSeconds)}</small
> >
</div> </div>
<div class="row-actions"> <div class="row-actions">
@@ -770,7 +823,13 @@
></label ></label
><button class="secondary-button" type="submit">Einplanen</button> ><button class="secondary-button" type="submit">Einplanen</button>
</form> </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 <input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden" type="hidden"
name="usageId" name="usageId"
@@ -778,7 +837,7 @@
/><button /><button
class="text-button danger-text" class="text-button danger-text"
type="submit" type="submit"
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button aria-label={`${usage.title} aus Sendung entfernen`}>Aus Sendung entfernen</button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
@@ -847,4 +906,14 @@
value={draggedPieceId} value={draggedPieceId}
/><input type="hidden" name="sectionId" value={dropSectionId} /> /><input type="hidden" name="sectionId" value={dropSectionId} />
</form> </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> </div>
+38
View File
@@ -7,6 +7,7 @@ import {
type TaxonomyKind type TaxonomyKind
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js'; import { getMailConfig } from '#lib/server/config.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { import {
inviteUser, inviteUser,
isRole, isRole,
@@ -25,6 +26,7 @@ export const load: PageServerLoad = ({ locals }) => {
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
users: listUsers(), users: listUsers(),
invitations: listOpenInvitations(), invitations: listOpenInvitations(),
tools: listTools(true),
mailTransport: getMailConfig().transport mailTransport: getMailConfig().transport
}; };
}; };
@@ -40,9 +42,25 @@ function actionError(cause: unknown): string {
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.'; return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.'; if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.'; if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.';
if (code === 'INVALID_TOOL_URL')
return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.';
if (code === 'INVALID_TOOL_FORMAT')
return 'Mindestens ein gewähltes Format existiert nicht mehr.';
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
return 'Die Aktion konnte nicht ausgeführt werden.'; return 'Die Aktion konnte nicht ausgeführt werden.';
} }
function toolInput(data: FormData): ToolInput {
return {
name: String(data.get('name') || ''),
startUrl: String(data.get('startUrl') || ''),
description: String(data.get('description') || ''),
isActive: data.get('isActive') === '1',
formatIds: data.getAll('formatIds').map(String)
};
}
export const actions = { export const actions = {
invite: async ({ locals, request }) => { invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
@@ -131,5 +149,25 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) }); return fail(400, { intent: 'taxonomy', error: actionError(cause) });
} }
},
createTool: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
createTool(toolInput(data));
return { intent: 'tool', success: 'Werkzeug angelegt.' };
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
},
updateTool: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
updateTool(String(data.get('toolId') || ''), toolInput(data));
return { intent: 'tool', success: 'Werkzeug aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
} }
} satisfies Actions; } satisfies Actions;
+84 -1
View File
@@ -12,7 +12,7 @@
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p> <p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#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} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
@@ -113,6 +113,89 @@
</section> </section>
</div> </div>
<h2 class="section-heading">Werkzeuge</h2>
<section class="quiet-panel tool-management">
<div class="panel-heading">
<div>
<p class="eyebrow">Redaktionelle Hilfsmittel</p>
<h2>Werkzeugkatalog</h2>
</div>
<small>Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.</small>
</div>
<div class="tool-settings-list">
{#each data.tools as tool (tool.id)}
<details class:inactive={!tool.isActive}>
<summary
><strong>{tool.name}</strong><span>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}</span
></summary
>
<form class="compact-form tool-form" method="POST" action="?/updateTool">
<input type="hidden" name="toolId" value={tool.id} />
<label>Name<input name="name" required maxlength="120" value={tool.name} /></label>
<label
>Start-URL<input
name="startUrl"
type="url"
maxlength="2000"
value={tool.startUrl}
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea name="description" rows="2" maxlength="2000"
>{tool.description}</textarea
></label
>
<fieldset class="wide-field">
<legend>Passende Formate</legend>
<div class="choice-grid">
{#each data.taxonomy.formats as format (format.id)}<label
><input
type="checkbox"
name="formatIds"
value={format.id}
checked={tool.formatIds.includes(format.id)}
/>{format.name}</label
>{/each}
</div>
</fieldset>
<label class="tool-active"
><input type="checkbox" name="isActive" value="1" checked={tool.isActive} /> Aktiv</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
</details>
{/each}
</div>
<details class="new-tool">
<summary>Werkzeug hinzufügen</summary>
<form class="compact-form tool-form" method="POST" action="?/createTool">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Start-URL<input
name="startUrl"
type="url"
maxlength="2000"
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea name="description" rows="2" maxlength="2000"></textarea></label
>
<fieldset class="wide-field">
<legend>Passende Formate</legend>
<div class="choice-grid">
{#each data.taxonomy.formats as format (format.id)}<label
><input type="checkbox" name="formatIds" value={format.id} />{format.name}</label
>{/each}
</div>
</fieldset>
<input type="hidden" name="isActive" value="1" />
<button class="primary-button" type="submit">Werkzeug anlegen</button>
</form>
</details>
</section>
<h2 class="section-heading">Taxonomie</h2> <h2 class="section-heading">Taxonomie</h2>
<div class="settings-grid"> <div class="settings-grid">
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
+198 -102
View File
@@ -31,11 +31,13 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
} }
async function openEditorialField(page: Page, heading: string) { async function openEditorialField(page: Page, heading: string) {
const field = page.locator('.editorial-field').filter({ if (heading.includes('Recherche')) {
has: page.getByRole('heading', { name: heading, exact: true }) await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
}); } else if (heading.includes('Präsentation')) {
await field.locator('.editorial-edit-button').click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
return field; } else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
}
} }
async function login(page: Page, email = adminEmail, password = adminPassword) { async function login(page: Page, email = adminEmail, password = adminPassword) {
@@ -188,6 +190,16 @@ function removePresentationFixture() {
database.close(); database.close();
} }
function setSectionDuration(sectionId: string, seconds: number | null) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database
.prepare('UPDATE show_sections SET explicit_duration_seconds = ? WHERE id = ?')
.run(seconds, sectionId);
database.close();
}
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('/'); await page.goto('/');
@@ -304,8 +316,25 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung'); await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(1); 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 page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
.click();
await expect(
page.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh aus Favoriten entfernen'
})
).toBeVisible();
await expect(page.locator('.piece-card').first()).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
const firstRow = await page.locator('.piece-card').nth(0).boundingBox(); const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox(); const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
@@ -317,11 +346,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
expect(searchBox?.height).toBe(statusBox?.height); expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart'); 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 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.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 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 page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
@@ -344,7 +379,13 @@ test('sent and planned source filters stay independent and removing the last usa
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0); ).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable')); 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.getByLabel('Auch verwendete Inhalte zeigen').check();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await expect( await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0); ).toHaveCount(0);
@@ -363,7 +404,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(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable'); await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte'); 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(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked();
await expect(usedPiece).toBeVisible(); await expect(usedPiece).toBeVisible();
await page.screenshot({ await page.screenshot({
@@ -372,7 +413,7 @@ test('sent and planned source filters stay independent and removing the last usa
}); });
await page.goto('/sendungen'); 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.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
@@ -424,9 +465,10 @@ test('sent and planned source filters stay independent and removing the last usa
fullPage: true fullPage: true
}); });
page.once('dialog', (dialog) => dialog.accept());
await page await page
.locator('.reserve-usage') .locator('.reserve-usage')
.getByRole('button', { name: /aus Reserve entfernen/ }) .getByRole('button', { name: /aus Sendung entfernen/ })
.click(); .click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck(); await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect( await expect(
@@ -519,9 +561,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 ({ test('a show can be planned from a template with independent usages and reserve', async ({
page page
}, testInfo) => { }, testInfo) => {
test.slow();
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen'); 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.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
@@ -582,16 +625,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' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie'); await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
const desktopEditorItems = await page await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
.locator('dialog[open] .usage-dialog-editor') await expectNoHorizontalOverflow(page);
.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 page.screenshot({ await page.screenshot({
path: testInfo.outputPath('verwendungseditor-desktop.png'), path: testInfo.outputPath('verwendungseditor-desktop.png'),
fullPage: true fullPage: true
@@ -611,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' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .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('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 usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt'); await expect(reactionSection).toContainText('Konsole als Auftakt');
@@ -627,9 +668,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' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .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('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 usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.'); await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
@@ -659,7 +705,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' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie'); await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect( await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' }) page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible(); ).toBeVisible();
@@ -687,7 +733,6 @@ test('a show can be planned from a template with independent usages and reserve'
); );
await expect(reserveUsage).toHaveClass(/is-dragging/); await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer }); await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveHandle.dispatchEvent('dragend', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose(); await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0); await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable'); await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
@@ -697,7 +742,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click(); .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(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const duplicatedUsage = page const duplicatedUsage = page
.locator('.editable-usage') .locator('.editable-usage')
@@ -708,7 +753,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' }) .getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click(); .click();
await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15'); 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' 'Andere Perspektive'
); );
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
@@ -717,15 +762,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click(); .click();
const mobileEditorItems = await page await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
.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 expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('verwendungseditor-mobile.png'), path: testInfo.outputPath('verwendungseditor-mobile.png'),
@@ -733,18 +770,16 @@ test('a show can be planned from a template with independent usages and reserve'
}); });
await page await page
.locator('dialog[open]') .locator('dialog[open]')
.getByRole('button', { name: 'Entfernen' }) .getByRole('button', { name: 'Aus Sendung entfernen' })
.scrollIntoViewIfNeeded(); .scrollIntoViewIfNeeded();
await expect( await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Entfernen' }) page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' })
).toBeVisible(); ).toBeVisible();
await page await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]') .locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
.evaluate((element) => ((element as HTMLInputElement).value = '1')); .evaluate((element) => ((element as HTMLInputElement).value = '1'));
await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('dialog[open]').getByRole('alert')).toContainText( await expect(page.getByRole('alert')).toContainText('inzwischen geändert');
'inzwischen geändert'
);
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
await page.getByRole('button', { name: 'Ablauf', exact: true }).click(); await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel'); await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
@@ -842,6 +877,49 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
expect(boxes[1]?.y).toBe(boxes[2]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y);
}); });
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung');
const create = page.locator('.new-tool');
await create.getByText('Werkzeug hinzufügen').click();
await create.getByLabel('Name').fill('E2E Tierlist');
await create.getByLabel('Start-URL').fill('https://example.com/tierlist');
await create.getByLabel('Beschreibung').fill('Ranking für die Sendung');
await create.getByLabel('Quiz').check();
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
const tool = page.locator('.tool-settings-list details').filter({ hasText: 'E2E Tierlist' });
await expect(tool).toContainText('Aktiv');
await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page
.locator('#piece-tool-picker')
.selectOption({ label: 'E2E Tierlist · passt zum Format' });
await page.getByRole('button', { name: 'Hinzufügen', exact: true }).click();
await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board');
await page.getByLabel('Notiz').fill('Vor der Sendung sortieren');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.goto('/verwaltung');
const storedTool = page
.locator('.tool-settings-list details')
.filter({ hasText: 'E2E Tierlist' });
await storedTool.getByText('E2E Tierlist', { exact: true }).click();
await storedTool.getByLabel('Aktiv', { exact: true }).uncheck();
await storedTool.getByRole('button', { name: 'Speichern' }).click();
await expect(storedTool).toContainText('Deaktiviert');
await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist');
await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert');
await expect(page.getByLabel('Arbeitslink')).toHaveValue(
'https://example.com/tierlist/e2e-board'
);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png'), fullPage: true });
});
test('validated YouTube and TikTok players switch without exposing private fields', async ({ test('validated YouTube and TikTok players switch without exposing private fields', async ({
page page
}, testInfo) => { }, testInfo) => {
@@ -861,9 +939,8 @@ test('validated YouTube and TikTok players switch without exposing private field
); );
await page.goto('/live?show=e2e-presentation'); await page.goto('/live?show=e2e-presentation');
const popupPromise = page.waitForEvent('popup'); 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; const output = await popupPromise;
await output.waitForLoadState('networkidle');
await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible(); await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible();
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click(); await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
@@ -905,9 +982,11 @@ test('live control persists state and exposes only explicit presentation data',
page, page,
browser browser
}, testInfo) => { }, testInfo) => {
setSectionDuration('section-stories', 300);
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); 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('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
const png = Buffer.from( const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
@@ -924,6 +1003,8 @@ test('live control persists state and exposes only explicit presentation data',
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible(); await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
await page.getByRole('button', { name: 'Sendung starten' }).click(); await page.getByRole('button', { name: 'Sendung starten' }).click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(page.locator('.section-countdown')).toContainText(/Noch 0[45]:\d{2}/);
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
await expect( await expect(
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen') page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
).not.toBeVisible(); ).not.toBeVisible();
@@ -942,9 +1023,9 @@ test('live control persists state and exposes only explicit presentation data',
const [popup] = await Promise.all([ const [popup] = await Promise.all([
page.waitForEvent('popup'), 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 outputUrl = popup.url();
const anonymous = await browser.newContext(); const anonymous = await browser.newContext();
const output = await anonymous.newPage(); const output = await anonymous.newPage();
@@ -960,14 +1041,19 @@ test('live control persists state and exposes only explicit presentation data',
timeout: 10_000 timeout: 10_000
}); });
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible(); await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible(); await expect(output.locator('.output-topics')).toHaveText('Gaming');
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0);
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true }); await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
const outputHtml = await output.content(); const outputHtml = await output.content();
expect(outputHtml).not.toContain('Herstellerangaben'); expect(outputHtml).not.toContain('Herstellerangaben');
expect(outputHtml).not.toContain('Erst Formfaktor zeigen'); expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
expect(outputHtml).not.toContain(adminEmail); expect(outputHtml).not.toContain(adminEmail);
expect(outputHtml).not.toContain('live-konsole.png'); expect(outputHtml).not.toContain('live-konsole.png');
const outputState = await anonymous.request.get(`${outputUrl}/state`); expect(outputHtml).not.toContain('https://example.com/console');
const stateUrl = new URL(outputUrl);
stateUrl.hash = '';
stateUrl.pathname += '/state';
const outputState = await anonymous.request.get(stateUrl.toString());
expect(outputState.status()).toBe(200); expect(outputState.status()).toBe(200);
const outputJson = await outputState.text(); const outputJson = await outputState.text();
expect(outputJson).toContain('Weniger Leistung'); expect(outputJson).toContain('Weniger Leistung');
@@ -997,9 +1083,11 @@ test('live control persists state and exposes only explicit presentation data',
await page.evaluate(() => { await page.evaluate(() => {
window.open = () => null; window.open = () => null;
}); });
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await expect( 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(); ).toBeVisible();
await anonymous.close(); await anonymous.close();
await popup.close(); await popup.close();
@@ -1012,33 +1100,41 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
}, testInfo) => { }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 }); await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus'); 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 page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url(); const pieceUrl = page.url();
await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown'); await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück mit Markdown');
await page.getByRole('link', { name: '← Fundus', exact: true }).click(); 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 expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl); await expect(page).toHaveURL(pieceUrl);
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); await page.getByText('Status, Dauer und Einordnung').click();
await page await page
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.'); .fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Eigene Gedanken & Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await page
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte'); await openEditorialField(page, 'Moderationsstichpunkte');
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.'); await page
await expect(page.getByLabel('Private Recherche')).toBeVisible(); .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); .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 openEditorialField(page, 'Präsentationstext');
await page await page
.getByLabel('Präsentationstext') .getByRole('textbox', { name: 'Öffentlicher Text', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); .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(); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last(); const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag'); await source.getByLabel('Name').fill('Originalbeitrag');
@@ -1047,19 +1143,15 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200); await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await page await openEditorialField(page, 'Präsentationstext');
.locator('.editorial-field') await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
.filter({ has: page.getByRole('heading', { name: 'Präsentationstext', exact: true }) }) await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
.getByRole('button', { name: 'Lesen' })
.click();
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload(); 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.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Eigene Gedanken & Recherche'); await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue( await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );
@@ -1087,9 +1179,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
const concurrent = await page.context().newPage(); const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl); 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 expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent await concurrent
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.'); .fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -1097,8 +1189,10 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
timeout: 10_000 timeout: 10_000
}); });
await concurrent.reload(); await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); await expect(concurrent.getByLabel('Titel', { exact: true })).toHaveValue(
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); 'E2E Fundstück, extern aktualisiert'
);
await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.' 'Diese veraltete Änderung darf nicht gewinnen.'
); );
@@ -1109,7 +1203,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect( await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible(); ).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 page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/); await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' }); const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
@@ -1118,26 +1212,26 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(archived).toHaveCount(0); await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert'); await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1); 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 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' 'Kopie von E2E Fundstück, extern aktualisiert'
); );
await page.context().setOffline(true); await page.context().setOffline(true);
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung'); await page.getByLabel('Titel', { exact: true }).fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', { await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000 timeout: 10_000
}); });
await page.context().setOffline(false); await page.context().setOffline(false);
await page.getByRole('button', { name: 'Jetzt speichern' }).click(); await page.getByRole('button', { name: 'Erneut speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark'); await setTheme(page, 'dark');
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Quellen', exact: true }).click(); await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Quellen schließen' }).click();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'), path: testInfo.outputPath('piece-editor-mobile-dark.png'),
@@ -1155,33 +1249,33 @@ test('a quickly captured video can be written and previewed before classificatio
}) })
); );
await page.goto('/eingang'); await page.goto('/eingang');
await page.getByLabel('Titel oder Gedanke').fill('Schnelles Video-Fundstück'); await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ'); await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Aufnehmen und öffnen' }).click(); await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
await expect(page.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute( await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
'href', await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ' 'https://youtu.be/dQw4w9WgXcQ'
); );
await expect(page.getByLabel('Status')).toBeHidden(); await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Eigene Gedanken & Recherche'); await openEditorialField(page, 'Private Recherche');
await page await page
.getByLabel('Private Recherche') .getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Warum funktioniert der Einstieg so unmittelbar?'); .fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte'); await openEditorialField(page, 'Moderationsstichpunkte');
await page await page
.getByLabel('Moderationsstichpunkte') .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.'); .fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Video').check(); await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup'); 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; const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute( await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src', 'src',
@@ -1191,9 +1285,9 @@ test('a quickly captured video can be written and previewed before classificatio
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Video')).toBeVisible(); await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Präsentation schließen' }).click(); await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
@@ -1201,21 +1295,21 @@ test('link preview uses one existing source, persists manual fields and isolates
page page
}, testInfo) => { }, testInfo) => {
await page.goto('/fundus/piece-console'); await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Linkvorschau').check(); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau'); 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 page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Linkvorschau')).toBeChecked(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue( await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau' 'Manuell korrigierte Linkvorschau'
); );
const previewPromise = page.waitForEvent('popup'); 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; const preview = await previewPromise;
await preview.waitForLoadState('networkidle'); await preview.waitForLoadState('networkidle');
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible(); await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
@@ -1236,8 +1330,10 @@ test('link preview uses one existing source, persists manual fields and isolates
}); });
await preview.close(); 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 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 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) => const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview') response.url().includes('?/loadPreview')
); );