10 Commits
Author SHA1 Message Date
hnzio fd47910d09 Refine live controls and compact show cards 2026-10-24 19:09:44 +02:00
hnzio 01b5606a3b Repair missing live timer schema 2026-10-24 14:40:03 +02:00
hnzio fa17187af4 Add persistent live controls and interaction notes 2026-10-24 14:31:33 +02:00
hnzio b1474bc33d Refine live control and show overview 2026-10-24 09:45:01 +02:00
hnzio ec596d442e Release 0.5.12 2026-10-24 02:08:32 +02:00
hnzio bd9ffe6faa Build compact live control view 2026-10-24 01:54:41 +02:00
hnzio 3389ba17f4 Fix topic icon picker placement 2026-10-23 23:55:34 +02:00
hnzio c05cea3a6d Release 0.5.11 2026-10-23 23:43:05 +02:00
hnzio 4fe61971e4 Release 0.5.10 2026-10-23 21:33:15 +02:00
hnzio c3033479b5 Release 0.5.9 2026-10-23 13:16:32 +02:00
68 changed files with 44010 additions and 1033 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.5.0", "version": "0.5.12",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+11
View File
@@ -0,0 +1,11 @@
# Third-party notices
## Font Awesome Pro Plus 7.0.0
The icon metadata in `src/lib/icons/classic-regular.ts` and the SVG sprite in
`static/icons/classic-regular.svg` are derived from Font Awesome Pro Plus 7.0.0
Classic Regular. Font Awesome Pro is commercial software and remains subject to
the Font Awesome Pro commercial license:
https://fontawesome.com/license
The Sendewerk MIT license does not apply to that icon data.
+169
View File
@@ -337,3 +337,172 @@
- `bash -n start.sh`: erfolgreich - `bash -n start.sh`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt; - reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen 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
## Editor-Erlebnis 0.5.10
| Kriterium | Nachweis | Stand |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Titel direkt und per Tastatur bearbeitbar | Chromium prüft Vorlage, Inline-Titel, Autosave, Schließen und erneutes Öffnen | erfüllt am 23.10.2026 |
| Gemeinsame Themen- und Werkzeugauswahl | Browser prüft Auswahl, Speicherung und deaktivierte Werkzeugzuordnung; Svelte-Check ist warnfrei | erfüllt am 23.10.2026 |
| Themenicons lokal und persistent | Migration und Servicetests prüfen Iconwert und Snapshot; Verwaltung nutzt lokalen Iconpicker | erfüllt am 23.10.2026 |
| Präsentationsbild ausdrücklich gewählt | Browser lädt Bild hoch, aktualisiert Usage und sieht nur dieses Bild in anonymer Ausgabe | erfüllt am 23.10.2026 |
| Medien- und Usage-Snapshots bleiben stabil | Servicetest entfernt Originalmedium und erhält bestehende Usage-Ausgabe | erfüllt am 23.10.2026 |
| Piece-Löschen ist wiederherstellbar | Servicetest prüft Revision, Soft Delete, Listenausblendung, Medienzugriff und Wiederherstellung | erfüllt am 23.10.2026 |
| Live-Verwendungen sicher entfernbar | Servicetests erlauben nur offene, nie gelaufene, nicht aktuelle oder präsentierte Verwendungen | erfüllt am 23.10.2026 |
| Leere OBS-Ausgabe ist transparent | Chromium prüft leeren Renderer vor und nach Präsentation sowie versteckten Verbindungsstatus | erfüllt am 23.10.2026 |
| Responsive Bedienung | vollständige Browsermatrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 23.10.2026 |
| Reales OBS, reale Anbieter und echtes iOS | In der lokalen Prüfumgebung nicht verfügbar | offen |
| Werkzeugkatalog entspricht Themenverwaltung | Kompakte Iconzeilen und gemeinsamer Anlegen-/Bearbeitendialog wurden auf Desktop/Mobil verglichen | erfüllt am 23.10.2026 |
| Vollständige Classic-Regular-Auswahl | Generierung liefert 3.671 Icons; seltenes Icon wurde gesucht, gespeichert und im Piece angezeigt | erfüllt am 23.10.2026 |
| Formatwahl nutzt gemeinsamen Picker | Browser ordnet Quiz im Dialog zu und erhält Format sowie Deaktivierungszustand nach Reload | erfüllt am 23.10.2026 |
## Ausgeführte Prüfungen 0.5.10
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 40 Tests in 7 Dateien erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, mobiler Navigation, Dialogen, Planung, Live-Steuerung, anonymer
Ausgabe, Medienauswahl und privater Datenfilterung erfolgreich; Screenshots
wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- lokale Entwicklungsdatenbank vorab nach
`.data/backups/0.5.10-pre-migration/sendewerk.sqlite` gesichert;
`npm run db:migrate` hat `0010_editor_experience.sql` erfolgreich angewendet
- vor der Werkzeugmigration zusätzlich nach
`.data/backups/0.5.10-tool-icons-pre-migration/sendewerk.sqlite` gesichert;
`npm run db:migrate` hat `0011_tool_icons.sql` erfolgreich angewendet
- Werkzeugkatalog, Anlegen-/Bearbeitendialog, geöffnete Iconauswahl und
Piece-Werkzeugauswahl bei 1280 × 720 und 390 × 844 Pixeln visuell direkt mit
der Themenverwaltung verglichen; keine horizontale Überlauffläche festgestellt
- `bash -n start.sh`: erfolgreich
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
## Verwaltungskorrektur 0.5.11
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- |
| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 23.10.2026 |
| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 23.10.2026 |
| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 23.10.2026 |
| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 23.10.2026 |
## Kompakte Live-Regie 0.5.12
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------- |
| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 |
| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 |
| Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | erfüllt am 24.10.2026 |
| Zweizeiliger Kopf gewinnt Inhaltsfläche | Chromium begrenzt die Kopfhöhe bei 670 × 450 und prüft die unveränderte feste Aktionsleiste | erfüllt am 24.10.2026 |
| Lange Kopftitel bleiben kontrolliert | Abschnitt und Folgetitel kürzen einzeilig und öffnen vollständig per Klick, Enter und Escape | erfüllt am 24.10.2026 |
| Regiemenü bleibt sichtbar und erreichbar | Browser prüft Viewportgrenzen, Scrollzugang, Sendungsname und Aktionen bei 1280, 670 und 390 Pixeln | erfüllt am 24.10.2026 |
| Menü schließt zugänglich | Escape und Außenklick schließen das Popover und führen den Fokus zum Auslöser zurück | erfüllt am 24.10.2026 |
| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 |
| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 |
| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 |
| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 |
| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 |
| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 |
| Zurück ist reine Betrachtungsnavigation | Browser prüft unverändertes aktuelles Piece, Countdown und Ausgabe sowie gesperrte Fortschrittsaktionen | erfüllt am 24.10.2026 |
| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 |
| Wiederaufnahme nur bei Unterbrechung | Während eines aktiven Pieces fehlt die Aktion; nach unterbrochenem Stand ist sie prominent | erfüllt am 24.10.2026 |
| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 |
| Liste und Kalender nutzen dieselben Daten | Chromium wechselt zwischen beiden Ansichten; Sendungslinks bleiben identisch erreichbar | erfüllt am 24.10.2026 |
| Kalenderpräferenz bleibt am Konto | Service- und Browsertest prüfen Standardwert, Speicherung und Reload | erfüllt am 24.10.2026 |
| Monat und Berliner Tage sind korrekt | Unit-Tests prüfen Monatsgrenzen und UTC-Zuordnung; Browser prüft Monatswechsel und heutigen Tag | erfüllt am 24.10.2026 |
| Mehrere und terminlose Sendungen bleiben sichtbar | Desktopkalender und mobile Agenda zeigen zwei Termine am Tag; eigener Bereich zeigt offenen Termin | erfüllt am 24.10.2026 |
## Ausgeführte Prüfungen 0.5.12
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 44 Tests in 8 Dateien erfolgreich
- gezielte Kalender- und Live-Regie-Läufe: 2 von 2 Chromium-Tests erfolgreich
- `npm run test:e2e`: 17 von 17 Chromium-Browsertests erfolgreich
- Browserprüfungen der Live-Regie bei 1440 × 900, 900 × 450, 670 × 450 und
390 × 700 Pixeln sowie des Kalenders bei 1440 × 1000 und 390 × 844 Pixeln
erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
## Erweiterte Live-Steuerung 0.5.12
| Kriterium | Stand |
| ---------------------------------------------- | ---------------------------- |
| Gemeinsame persistente Pause | implementiert, nicht geprüft |
| Abschnitts- und Gesamtreset bleiben unabhängig | implementiert, nicht geprüft |
| Gesamt-/Restzeit ohne Doppelerfassung | implementiert, nicht geprüft |
| Ende, Wiederaufnahme und neuer Lauf getrennt | implementiert, nicht geprüft |
| Privates Feld „Interaktion“ durch alle Kopien | implementiert, nicht geprüft |
| Öffentliche Positivliste unverändert | implementiert, nicht geprüft |
| Classic-Regular-Icons und Drawer-Schließen | gezielt im Browser geprüft |
Für die ursprüngliche Erweiterung wurden auf ausdrücklichen Auftrag zunächst
keine Prüfungen ausgeführt. Die nachfolgenden Korrekturen weisen inzwischen
Migration, Typen, Build, bestehendes Liveverhalten und die betroffenen
Oberflächen gezielt nach; die übrigen neuen Timer- und Statusfälle bleiben ohne
vollständige erneute Matrix.
## Live-500-Korrektur 0.5.12
- Ursache auf einer schreibgeschützt untersuchten lokalen Datenbank bestätigt:
`0013` war protokolliert, `live_show_timer_runs` fehlte jedoch.
- `0014_live_show_timer_repair.sql` auf einer Kopie dieser Datenbank angewendet
und erneut ausgeführt; Tabelle und Indizes wurden angelegt, 13 bestehende
Intervalle genau einmal übernommen und ein offenes Intervall erhalten.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run test:unit -- --run src/lib/server/live.spec.ts`: 6 Tests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
## Live-Icons und kompakte Sendungskarten 0.5.12
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run test:unit -- --run src/lib/server/live.spec.ts`: 6 Tests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- Gezielter Chromium-Ablauf der Sendungsübersicht mit Desktopkalender, mobiler
Agenda und Rückkehr zur Liste: erfolgreich.
- Vollständiger bestehender Live-Regie-Ablauf einschließlich 900 × 450,
670 × 450 und 390 × 700 Pixeln: erfolgreich; Iconleiste, Kopfhöhe,
Viewportgrenzen und horizontaler Überlauf geprüft.
- Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln:
Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und
Pausebutton wechselt bedienbar zu Fortsetzen.
+104 -22
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` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; revisionierte Vorlagen speichern Snapshots | | Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen 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-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
| 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 Abschnitts-/Gesamttimer 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,15 @@ 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.
Der Gesamttimer speichert ebenfalls mehrere Laufintervalle. Eine gemeinsame
Pause schließt die offenen Piece-, Abschnitts- und Gesamtintervalle; Fortsetzen
öffnet neue Intervalle, ohne die Pause als Laufzeit zu zählen. Abschnitts- und
Gesamtreset ersetzen ausschließlich die jeweilige Zeitbasis.
## 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,6 +106,13 @@ 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 serverseitig validiertes `icon_name` gehört
zum Werkzeugkatalog und wird im Originaleditor angezeigt, ohne bestehende
Usage-Snapshots nachträglich zu verändern.
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
@@ -126,6 +142,15 @@ sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]`
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`, prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus. Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
Ein Piece kann genau eines seiner aktiven Medien ausdrücklich als
Präsentationsbild auswählen. Die Auswahl liegt in `piece_presentation_media`;
das Entfernen setzt am Medium einen Zeitstempel statt die Datei zu löschen.
Damit bleiben bereits erzeugte `usage_media_snapshots` auflösbar, während neue
Einplanungen und der Originaleditor nur aktive Medien berücksichtigen. Das
Soft Delete eines Pieces setzt ebenfalls nur `deleted_at_utc`: normale Listen
und neue Einplanungen schließen es aus, bestehende Verwendungsreferenzen und
Snapshots bleiben unverändert erhalten.
## Sendungsbau ## Sendungsbau
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine
@@ -135,9 +160,12 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt. 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 Interaktion, Ü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, die
Themennamen samt Icon sowie das ausdrücklich ausgewählte Präsentationsmedium.
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
@@ -168,6 +196,13 @@ 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 Sendungsübersicht leitet Liste, Desktop-Monatsraster und mobile Monatsagenda
aus derselben `listShows()`-Abfrage ab. UTC-Zeitpunkte werden erst für Gruppierung
und Anzeige nach `Europe/Berlin` in zivile Kalendertage übersetzt. Sendungen ohne
Startzeit bleiben in einem eigenen Bereich sichtbar. `users.show_overview_view`
speichert ausschließlich die kontobezogene Darstellungspräferenz; der gewählte
Monat bleibt URL-Zustand und verändert keine Sendungsdaten.
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
@@ -202,6 +237,15 @@ liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
geplanten Dauer nachvollziehbar. geplanten Dauer nachvollziehbar.
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden setzt den
Sendungsstatus auf `completed`, schließt laufende Intervalle, markiert offene
Pieces aber nicht als erledigt. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten
und Fortschritt; nur „Neuer Durchlauf“ verwirft Laufintervalle und setzt die
Piece-Status zurück. Die Restzeitschätzung verwendet je Abschnitt entweder die
positive Abschnittsdauer oder die verbleibenden Piece-Dauern und zählt beide nie
zusammen.
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 nutzt Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
@@ -212,14 +256,20 @@ rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
erneutes Öffnen behält ihn bei. erneutes Öffnen behält ihn bei.
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen Das private Markdownfeld „Interaktion“ wird am Original und als
Positivliste ab. Implizite Piece-Quellen und private Uploads werden nicht Verwendungssnapshot gespeichert und ausschließlich in der authentifizierten
veröffentlicht; nur die ausdrücklich gewählte Link- oder Videoquelle und deren Live-Regie gerendert. Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
erlaubte Vorschau-URL können die Grenze passieren. Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen Grenze passieren.
erst nach einer ausdrücklichen Snapshot-Aktualisierung. Lokale Betrachtungsnavigation, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Zurück und die Auswahl einer Ablaufzeile wechseln nur
den im Browser betrachteten Verwendungssnapshot; tatsächliches Live-Piece,
Status, Timer und Ausgabe bleiben serverseitig unverändert. Die öffentliche
Projektion liest ausschließlich den ausdrücklich präsentierten
Verwendungssnapshot; spätere Änderungen am Original erscheinen erst nach einer
ausdrücklichen Snapshot-Aktualisierung.
Linkmetadaten werden nur auf ausdrückliche Aktion durch Linkmetadaten werden nur auf ausdrückliche Aktion durch
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost `src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
@@ -254,10 +304,42 @@ die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
touchfreundliche Agenda desselben Monats.
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation. ohne interne Anwendungsnavigation.
Die private Route `/live` verwendet ebenfalls einen eigenen authentifizierten
Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder
Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster
aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie
fester Aktionszeile. Die Breite entscheidet über zwei Spalten oder das mobile
Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten,
ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints
wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung
eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand
aufgehoben. Die lokale Betrachtungsnavigation ist keine Live-Action; erst
Präsentieren, Überspringen und Erledigen schreiben den jeweiligen getrennten
Zustand. Die öffentliche `/heinzlive`-Projektion bleibt unverändert. Lokale
Lesepositionen sind an die jeweilige Verwendung gebunden und werden nicht
zwischen Pieces übertragen.
Der Live-Kopf besteht aus zwei regulären Rasterzeilen. Abschnitt und folgendes
Piece sind einzeilig gekürzte, fokussierbare Schaltflächen, die ihren vollständigen
Text lokal aufklappen. Das Regiemenü wird bei Bedarf als fest am Viewport
positionierter Dialog neben dem Kopf gerendert. Dadurch unterliegt es weder dem
Overflow der Regiefläche noch dem durch Glasfilter erzeugten Stacking Context;
seine maximale Höhe bleibt an den Viewport gebunden und intern scrollbar.
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog
nicht in ihren Client-Chunk.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung, Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche. Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
+115
View File
@@ -310,3 +310,118 @@ geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
Einplanen getrennt in den Verwendungssnapshot kopiert. 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.
## D-023: Ausdrückliche Medienauswahl und wiederherstellbares Entfernen
Ein privater Piece-Upload wird nicht allein durch seine Existenz öffentlich.
Das Original verweist ausdrücklich auf höchstens ein aktives
Präsentationsmedium. Beim Einplanen oder bewussten Aktualisieren wird diese
Auswahl mit Medium-ID und Alternativtext an die Verwendung kopiert. Die
öffentliche Route akzeptiert nur das Medium des aktuell präsentierten
Usage-Snapshots; Originalname, interner Speicherpfad und andere Uploads bleiben
außerhalb der Projektion.
Medien und Pieces werden nicht physisch gelöscht, solange bestehende
Sendungsfassungen auf ihre unveränderlichen Snapshots angewiesen sein können.
Zeitstempel blenden sie aus aktuellen Arbeitslisten und neuen Einplanungen aus,
erhalten aber Referenzen und ermöglichen die Wiederherstellung von Pieces. Eine
Verwendung oder ein Abschnitt darf dagegen nur entfernt werden, solange keine
betroffene Verwendung jemals live gelaufen ist und weder aktuell noch
präsentiert wird.
Der lokale Iconkatalog enthält die vollständige Classic-Regular-Familie mit
3.671 Icons. Ein generierter Metadatenkatalog ermöglicht Suche und serverseitige
Validierung, während ein statischer SVG-Sprite über den gemeinsamen
`currentColor`-Renderer dargestellt wird. So lädt die Navigation nicht sämtliche
Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf
noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und
Lizenz stehen in `THIRD_PARTY_NOTICES.md`.
## D-024: Private Live-Regie als fokussierter Viewport
Die authentifizierte Live-Steuerung nutzt einen eigenen Root-Layoutmodus und
rendert die allgemeine App-Navigation nicht. Kopf- und Aktionszeile bleiben fest;
Moderation und Ablauf sind die einzigen unabhängig scrollenden Arbeitsflächen.
Die Umschaltung zum mobilen Sheet hängt von der Fensterbreite ab, damit ein
breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet. Die Höhe
steuert dort unabhängig die Dichte von Abständen und Bedienleisten. Textgröße
und Gesamtoberfläche werden nicht skaliert.
Der Kopf verwendet genau zwei kompakte Informationszeilen. Lange Kontexttitel
bleiben im Ruhezustand einzeilig und werden auf bewusste Klick- oder
Tastaturaktion lokal vollständig sichtbar. Das Regiemenü ist kein verschachteltes
`details` innerhalb der Glasfläche, sondern ein explizit gesteuertes,
viewportgebundenes Popover. So bleiben Z-Index, Maximalhöhe, Escape,
Außenklick und Fokus-Rückkehr unabhängig von Overflow und Filter-Stacking-
Contexts kontrollierbar.
Diese Darstellung ist keine neue Live-Zustandsgrenze. Zurück und die direkte
Auswahl im Ablauf sind reine lokale Betrachtungsnavigation. Sie verändern weder
aktuelles Live-Piece noch Status, Zeitintervalle, Abschnittstimer oder Ausgabe.
Während ein anderes Piece betrachtet wird, bleiben Fortschrittsaktionen
gesperrt; Präsentieren darf dagegen weiterhin ausdrücklich genau das betrachtete
Piece ausgeben. Start, Erledigen, Überspringen, Reserveeinfügung und Präsentation
bleiben serverseitige Actions mit ihren bisherigen Revisions- und
Idempotenzregeln.
## D-025: Gemeinsame Sendungsdaten für Liste und Kalender
Listenansicht, Desktop-Monatsraster und mobile Monatsagenda werden aus derselben
kompakten Sendungsliste abgeleitet. Es gibt kein zweites Kalenderdatenmodell und
keine Bibliothek für Terminverwaltung. UTC-Zeitpunkte werden mit
`Europe/Berlin` einem zivilen Tag zugeordnet; der ausgewählte Monat bleibt als
URL-Parameter navigierbar. Mehrere Sendungen eines Tages werden vollständig und
chronologisch gezeigt, Sendungen ohne Startzeit separat.
Die Präferenz `list` oder `calendar` gehört zum angemeldeten Konto und wird als
kleines geprüftes Feld an `users` gespeichert. Sie ist weder Bestandteil einer
Sendung noch Browserzustand. Der Kalender dient nur der Navigation und erhält
bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die
bestehende serverseitig validierte Sendungsbearbeitung erfolgt.
## D-026: Gemeinsame Live-Pause und getrennte Zeitbasen
Piece-Laufzeit, Abschnittszeit und Gesamtzeit bleiben getrennte persistente
Intervallmengen. Pause schließt in einer Live-Transaktion alle offenen
Intervalle, Fortsetzen eröffnet sie am gespeicherten Fortschritt erneut. Dadurch
zählt weder Browserwechsel noch eine beliebig lange Pause zur Sendezeit. Ein
Abschnittsreset ersetzt nur den aktiven Abschnittstimer; ein Gesamtreset löscht
nur die Gesamtlaufintervalle. Beide ändern weder Piece-Status noch Reihenfolge,
Restplanung oder Präsentationsstand.
Die Restzeit ist eine Ableitung, kein zweiter Ablaufplan. Für jeden verbleibenden
Abschnitt gilt entweder dessen positive explizite Dauer oder die Summe seiner
verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen,
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
nicht die geplante Zeit kommender Inhalte.
`completed` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
Präsentations-Positivliste.
+244
View File
@@ -0,0 +1,244 @@
# Release Notes
## 0.5.12 - Live-Regie und Sendungskalender
Veröffentlicht am 24. Oktober 2026
Sendewerk 0.5.12 macht aus der privaten Live-Steuerung eine kompakte Regieansicht
für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Die
Sendungsübersicht erhält zusätzlich eine kontobezogen gespeicherte Listen- und
Kalenderansicht. Die öffentliche heinzlive-Ausgabe und die gespeicherten
Livezustände bleiben technisch getrennt.
### Highlights
- Ausgabe, Gesamtzeit, Abschnittszeit und Menü tragen in der Live-Kopfleiste
jetzt die passenden lokalen Classic-Regular-Icons. Pause beziehungsweise
Fortsetzen ist zusätzlich als direkter Iconbutton neben der Abschnittszeit
erreichbar.
- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale
App-Seitenleiste, mobilen App-Header oder Bottomnavigation.
- Zwei kompakte Kopfzeilen halten Abschnitt, „Danach“, Menü, Ausgabe,
Abschnittscountdown und Ablauf gleichzeitig erreichbar. Der Sendungsname ist
in das Menü gewandert.
- Lange Abschnitts- und Folgetitel bleiben einzeilig gekürzt und lassen sich per
Klick oder Tastatur vollständig aufklappen, ohne benachbarte Bedienelemente zu
überlagern.
- Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen
Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer
Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar.
- Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der
verfügbaren Höhe, ohne Text oder die gesamte Oberfläche zu skalieren.
- Die feste Fußleiste ist auf „Zurück“, „Präsentieren“, „Überspringen“ und
„Erledigen & weiter“ reduziert. Das nächste Piece steht in der Kopfzeile, das
Leeren der Präsentation direkt am Ausgabezustand.
- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet,
während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und
der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber.
### Moderation und Materialien
- Interner Titel, Einleitung, Moderationsstichpunkte und Überleitung erscheinen
in klarer Lesereihenfolge. Leere Bereiche werden weggelassen.
- Recherche, Quellen, Werkzeuge und eigene Notizen sind kompakt beschriftet und
werden nur bei Bedarf geöffnet.
- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal
gespeichert. Texte werden nicht automatisch verkleinert.
- Piece-Position, Piece-Titel und Zeit stehen in einer gemeinsamen kompakten
Kopfzeile; dadurch beginnt die Einleitung weiter oben.
### Ablauf und Liveverhalten
- Pause und Fortsetzen steuern Piece-, Abschnitts- und Gesamtzeit gemeinsam und
zählen Pausen auch nach Reload oder Navigation nicht mit.
- Gelaufene Gesamtzeit und geschätzte Restzeit lassen sich direkt umschalten;
die Auswahl bleibt lokal erhalten. Ungeplante verbleibende Abschnitte sind
sichtbar, ohne Abschnitts- und Piece-Dauern doppelt zu zählen.
- Abschnitts- und Gesamtzeit lassen sich getrennt nach Bestätigung zurücksetzen.
Ablaufposition, Piece-Status, Restplanung und Präsentation bleiben dabei
unverändert.
- Sendungen lassen sich ausdrücklich vorbereiten, starten, pausieren und
beenden. Nach dem Ende sind „Lauf fortsetzen“ und „Neuer Durchlauf“ getrennte
Aktionen; offene Pieces werden beim Beenden nicht automatisch erledigt.
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
das aktive Piece bleibt in der Ablaufspalte sichtbar.
- Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und
ausgegebenes Piece getrennt. Jeder Eintrag lässt sich zur Betrachtung wählen,
einschließlich des ersten Eintrags.
- Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und
die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über
eine kompakte Aktion am jeweiligen Eintrag erreichbar.
- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte
Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben.
- „Zurück“ und direkte Ablaufwahl verändern weder aktuelles Live-Piece noch
Erledigungsstatus, Timer oder Ausgabe. Bei einem nur betrachteten früheren
Piece bleiben Überspringen und Erledigen gesperrt. Nur „Präsentieren“ oder
„Präsentation leeren“ verändert die öffentliche Ausgabe.
- Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme
verwenden unverändert die vorhandenen persistenten Zustände.
- „Live-Stand wiederaufnehmen“ erscheint nur bei einem tatsächlich
unterbrochenen Ablauf, nicht während eines aktiven Pieces.
### Einstellungen
- Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln,
Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar.
- Das Regiemenü erscheint als viewportgebundenes Popover sicher über den
Glasflächen. Es bleibt in kleinen Fenstern scrollbar erreichbar und schließt
mit Escape oder Außenklick; der Fokus kehrt zum Menüauslöser zurück.
### Interaktion
- „Interaktion“ ergänzt Piece und Sendungsfassung als optionales privates
Markdownfeld zwischen Moderationsstichpunkten und Überleitung.
- Vorlagen, Duplikate, Einplanungen und die bewusste Aktualisierung aus dem
Fundus übernehmen das Feld jeweils als unabhängigen Snapshot.
- Die private Live-Regie zeigt das Feld nur bei Inhalt. Die öffentliche
heinzlive-Ausgabe übernimmt es nicht.
### Sendungsübersicht
- Sendungskarten bleiben auf kleinen Bildschirmen kompakt: Status, Titel und
Termin stehen neben den horizontal angeordneten Abschnitts- und Zeitangaben,
statt die Karte durch vollständiges Stapeln unnötig zu erhöhen.
- „Liste | Kalender“ schaltet zwischen der bekannten Sendungsliste und einer
Monatsansicht um; die Auswahl bleibt am angemeldeten Konto gespeichert.
- Auf dem Desktop zeigt ein vollständiges Montag-bis-Sonntag-Raster alle Termine
des Monats. Mehrere Sendungen pro Tag bleiben vollständig sichtbar und nach
Uhrzeit sortiert.
- Auf dem Smartphone wird derselbe Monat als chronologische Agenda dargestellt.
Vorheriger Monat, nächster Monat und der Sprung zu heute bleiben erreichbar.
- Die Gruppierung verwendet `Europe/Berlin`. Sendungen ohne Startzeit stehen in
einem eigenen Bereich und verschwinden weder aus Liste noch Kalender.
- Der Kalender ist eine Navigationsansicht. Termine werden weiterhin nur über
die bestehende Sendungsbearbeitung geändert; Termin-Drag-and-drop wurde nicht
ergänzt.
### Update und Kompatibilität
- `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene
Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
- Es ist keine Änderung der Konfiguration erforderlich.
- `0013_live_controls_and_interaction.sql` ergänzt das private Interaktionsfeld
und persistente Gesamtzeitintervalle. Bestehende Piece-Laufintervalle werden
als Gesamtzeit übernommen.
- Bestehende Sendungen, Livezustände, Abschnittstimer, Ausgabekanäle und
Präsentationssnapshots bleiben erhalten.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
### Prüfung
- Die ergänzte Iconleiste, Pause/Fortsetzen und die kompakte Sendungskarte wurden
gezielt in Chromium bei 670 × 450, 390 × 700 und 656 × 400 Pixeln geprüft.
Typprüfung, sechs Live-Verhaltenstests und Produktionsbuild sind erfolgreich.
- Die vollständige Matrix wurde für diese Ergänzung nicht erneut ausgeführt; die
folgenden Gesamtnachweise stammen vom vorherigen 0.5.12-Stand.
- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
mobilem Ablauf-Sheet geprüft; der Kalender zusätzlich bei 1440 × 1000 und
390 × 844 Pixeln.
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung.
## 0.5.11 - Verwaltungskorrektur
Veröffentlicht am 23. Oktober 2026
- Der Dialog „Werkzeug hinzufügen“ bleibt beim Aufruf der Verwaltungsseite
wieder zuverlässig geschlossen.
- Der Dialog erscheint erst, wenn ein neues Werkzeug angelegt oder ein
bestehendes Werkzeug bearbeitet wird.
- Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und
Smartphone vollständig im sichtbaren Browserbereich.
- Die Korrektur gilt gleichermaßen für Desktop- und Smartphone-Ansichten und
verändert keine vorhandenen Werkzeuge oder Zuordnungen.
## 0.5.10 - Editor-Erlebnis
Veröffentlicht am 23. Oktober 2026
Sendewerk 0.5.10 beschleunigt die tägliche Redaktionsarbeit und schärft die
Grenze zwischen privaten Medien und der öffentlichen Präsentation. Titel,
Themen, Werkzeuge und Präsentationsbilder lassen sich direkter bearbeiten; die
wichtigsten Listen und Dialoge benötigen auf Desktop und Smartphone weniger
Platz. Bestehende Sendungsfassungen bleiben bei späteren Änderungen am Fundus
stabil.
### Highlights
- Titel von Fundus- und Sendungsfassungen lassen sich direkt in der jeweiligen
Ansicht ändern. Die Bedienung funktioniert auch vollständig per Tastatur.
- Themen und Werkzeuge verwenden kompakte, durchsuchbare Auswahlflächen. Der
Werkzeugkatalog folgt nun demselben Bedienmuster wie die Themenverwaltung.
- Für Themen, Werkzeuge, Navigation und Aktionen stehen 3.671 lokal
bereitgestellte Classic-Regular-Icons zur Verfügung. Zur Laufzeit werden
keine Icons von externen Diensten geladen.
- Präsentationsbilder werden bewusst aus den aktiven Piece-Medien gewählt und
beim Einplanen in die Sendungsfassung übernommen. Private Uploads werden nicht
allein durch das Hochladen öffentlich.
- Pieces lassen sich wiederherstellbar löschen. Bereits vorbereitete
Sendungsfassungen und ihre Medien-Snapshots bleiben dabei erhalten.
- Die heinzlive-Ausgabe kann ausdrücklich geleert werden. Im leeren Zustand ist
sie vollständig transparent und zeigt auch keinen Verbindungsstatus.
### Redaktion und Verwaltung
- Der Werkzeugkatalog entspricht der Themenverwaltung: kompakte Glasfläche,
Icon, Name, Status und Bearbeitungsaktion statt dauerhaft offener Formulare.
- Anlegen und Bearbeiten eines Werkzeugs verwenden denselben responsiven Dialog
für Name, Start-URL, Beschreibung, Icon, passende Formate und Aktivstatus.
- Die Formatzuordnung nutzt dieselbe durchsuchbare Mehrfachauswahl wie andere
Zuordnungen. Deaktivierte Werkzeuge und bestehende Piece-Zuordnungen bleiben
erhalten.
- Fundus, Eingang, Archiv, Vorlagen und Sendungsansichten sind kompakter und
bleiben per Klick, Touch und Tastatur bedienbar.
### Pieces und Präsentation
- Präsentationsbilder werden ausdrücklich aus aktiven Piece-Medien ausgewählt
und unveränderlich an Sendungsfassungen kopiert.
- Entfernte Medien bleiben für bestehende Sendungsfassungen verfügbar, werden
aber nicht mehr für neue Einplanungen angeboten.
- Pieces können wiederherstellbar gelöscht werden. Offene, nie live gelaufene
Verwendungen und Abschnitte lassen sich unter geschützten Bedingungen
entfernen.
- Themenicons werden zusammen mit der jeweiligen Sendungsfassung gespeichert.
Spätere Änderungen an Thema, Werkzeug oder Original-Piece verändern eine
vorbereitete Sendung weiterhin nur nach ausdrücklicher Aktualisierung aus dem
Fundus.
### Live-Ausgabe
- Eine leere heinzlive-Ausgabe ist vollständig transparent und zeigt weder
Renderer noch Verbindungsstatus.
- Eine aktive Präsentation lässt sich unabhängig vom aktuell laufenden Piece
leeren.
### Update und Kompatibilität
- `0010_editor_experience.sql` ergänzt Themenicons, Medien-Snapshots und Soft
Delete.
- `0011_tool_icons.sql` ergänzt persistente Werkzeugicons mit einem sicheren
Standardwert. Werkzeug-IDs, Formatbeziehungen, Piece-Zuordnungen und
Deaktivierungszustände bleiben erhalten.
- Die Migrationen laufen beim nächsten Serverstart automatisch. Vor dem Update
sollte wie üblich eine konsistente Sicherung der SQLite-Datenbank und des
Upload-Verzeichnisses angelegt werden.
- Für dieses Release sind keine manuellen Datenkonvertierungen und keine
Änderungen an der Konfiguration erforderlich.
### Prüfung
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe erfolgreich.
- Werkzeugverwaltung, Iconauswahl und Piece-Werkzeugauswahl wurden zusätzlich
bei 1280 × 720 und 390 × 844 Pixeln visuell mit der Themenverwaltung
verglichen.
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung.
### Noch nicht enthalten
Der Abschluss und die Archivierung vollständiger Sendungen, externe
Lesefreigaben, RSS-Import und API gehören zu späteren Etappen und sind nicht Teil
von 0.5.10.
+139
View File
@@ -7,6 +7,145 @@ 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-Regie und Sendungskalender 0.5.12
- die Live-Kopfleiste zeigt Classic-Regular-Icons für Ausgabe, Gesamtzeit,
Abschnitt, Menü und Ablauf; Pause/Fortsetzen liegt zusätzlich als direkter,
beschrifteter Iconbutton neben der Abschnittszeit
- Sendungskarten ordnen Fakten auf kleinen Bildschirmen wieder horizontal an und
vermeiden dadurch die zuvor unnötig hohe Kartenfläche
- Reparaturmigration `0014_live_show_timer_repair.sql` ergänzt fehlende
Gesamttimertabellen auch dann, wenn `0013` bereits in einem früheren
Zwischenstand protokolliert wurde; der 500-Fehler beim Laden von `/live` ist
damit behoben
- gemeinsame persistente Pause für Piece-, Abschnitts- und Gesamttimer;
Fortsetzen zählt Pausenzeit weder nach Navigation noch nach Reload mit
- getrennte bestätigungspflichtige Resets für Abschnitts- und Gesamtzeit, ohne
Piece-Status, Ablaufposition, Restplanung oder Präsentationsstand zu verändern
- umschaltbare, lokal gemerkte Anzeige von gelaufener Gesamtzeit und geschätzter
Restzeit; Abschnitte ohne eigene Planung werden kenntlich gemacht und
Abschnitts- sowie Piece-Dauern nie doppelt addiert
- explizite Sendungssteuerung für Entwurf, Bereit, Start, Pause, Ende,
Wiederaufnahme eines beendeten Laufs und klar getrennten neuen Durchlauf
- optionales privates Markdownfeld „Interaktion“ zwischen Moderation und
Überleitung; Original, Vorlage, Sendungsfassung, Duplikation und bewusste
Fundus-Aktualisierung führen jeweils unabhängige Snapshots
- „Interaktion“, Timer- und Sendungssteuerung bleiben aus der öffentlichen
heinzlive-Projektion ausgeschlossen
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
- zweizeilige feste Kopfzeile: Abschnitt und „Danach“ teilen sich die erste
Zeile mit dem Menü; Ausgabe, Abschnittscountdown und Ablaufschalter bilden die
zweite Zeile. Der Sendungsname steht platzsparend im Menü
- lange Abschnitts- und Folgetitel sind regulär einzeilig gekürzt und lassen
sich per Klick oder Tastatur vollständig aufklappen
- feste, auf vier Kernaktionen reduzierte Fußleiste mit Zurück, Präsentieren,
Überspringen und kontextgerechtem Erledigen; das Leeren der Präsentation liegt
direkt beim Ausgabezustand
- aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln
nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite
oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen
- bei niedrigen Desktopfenstern werden Abstände und Bedienleisten anhand der
verfügbaren Höhe verdichtet, ohne Text oder Gesamtoberfläche zu skalieren
- auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet
als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, lokaler
Betrachtung jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion
erreichbar, dauerhafte Sortierpfeile entfallen
- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece
beginnt oben statt an der Position des vorherigen Textes
- Piece-Position, Titel und Zeit teilen sich einen kompakten Piece-Kopf, ohne die
einstellbare Moderationsschrift zu verkleinern
- das Regiemenü liegt als viewportgebundenes Popover sicher über den Glasflächen,
bleibt scrollbar erreichbar und schließt per Escape oder Außenklick mit
Fokus-Rückkehr zum Auslöser
- Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf;
leere Moderationsbereiche werden nicht dargestellt
- Zurück und direkte Ablaufwahl verändern weder tatsächliches Live-Piece noch
Status, Timer oder öffentliche Ausgabe; Überspringen und Erledigen bleiben bei
einem nur betrachteten früheren Piece gesperrt
- Liste und Kalender teilen dieselben Sendungsdaten; der Desktop zeigt ein
vollständiges Monatsraster, das Smartphone eine chronologische Monatsagenda
- Monatswechsel, heutiger Tag, mehrere Termine pro Tag und Sendungen ohne Termin
bleiben sichtbar; die bevorzugte Listen- oder Kalenderansicht wird je Konto
gespeichert
- die Wiederaufnahmeaktion erscheint nur bei tatsächlich unterbrochenem
Live-Stand statt während eines laufenden Pieces
Etappe 6 bleibt unverändert unbegonnen.
Die bestehende lokale Prüfmatrix umfasst 44 Verhaltenstests und 17
Chromium-Abläufe. Die nachträgliche Icon-, Pause- und Kartenkorrektur wurde
zusätzlich mit Typprüfung, sechs Live-Verhaltenstests, Produktionsbuild und
gezielten Chromium-Abläufen bei 670 × 450, 390 × 700 und 656 × 400 Pixeln
geprüft. Die vollständige Matrix wurde dafür nicht erneut ausgeführt.
Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
bleiben mangels verfügbarer Umgebung offen.
## Verwaltungskorrektur 0.5.11
- der Werkzeugdialog bleibt beim Laden geschlossen und öffnet erst nach einer
bewussten Aktion
- die Themen-Iconauswahl bleibt bei mobilen und breiten Ansichten vollständig
innerhalb des Viewports
## Editor-Erlebnis 0.5.10
- direkte, tastaturbedienbare Titelbearbeitung in Fundus- und Sendungsfassungen
sowie gemeinsame kompakte Auswahlflächen für Themen und Werkzeuge
- vollständiger lokaler Classic-Regular-Katalog mit 3.671 Icons für Navigation,
Aktionen sowie frei wählbare Themen- und Werkzeugicons; durchsuchbare
Metadaten und SVG-Sprite vermeiden externe Laufzeitabhängigkeiten
- kompakter Werkzeugkatalog nach dem Muster der Themenverwaltung; Anlegen und
Bearbeiten teilen einen responsiven Dialog mit gestalteter Icon- und
Formatauswahl, bestehende Zuordnungen und Deaktivierung bleiben erhalten
- Präsentationsbilder werden bewusst aus aktiven Piece-Medien ausgewählt und als
unveränderlicher Usage-Snapshot eingeplant; private Bilder werden dadurch
weder implizit noch nach späterer Originaländerung öffentlich
- entfernte Medien bleiben für bestehende Sendungsfassungen erhalten, während
neue Einplanungen nur aktive Medien übernehmen
- wiederherstellbares Soft Delete für Pieces sowie geschütztes Entfernen offener,
nie live gelaufener Verwendungen und Abschnitte
- kompaktere Fundus-, Eingangs-, Archiv-, Vorlagen- und Sendungsansichten mit
mobilen, touch- und tastaturbedienbaren Alternativen
- transparente leere heinzlive-Ausgabe ohne sichtbaren Renderer oder
Verbindungsstatus; die Steuerung kann eine aktive Präsentation unabhängig vom
aktuellen Piece leeren
Etappe 6 bleibt unverändert unbegonnen. Die lokale Prüfmatrix umfasst 40
Verhaltenstests und 16 Chromium-Abläufe. Reales OBS, echte
YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät bleiben mangels
verfügbarer Umgebung offen.
## 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 ## Redaktionelle Arbeitsabläufe 0.5.8
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene - persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
+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
);
+49
View File
@@ -0,0 +1,49 @@
ALTER TABLE topics ADD COLUMN icon_name TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN deleted_at_utc TEXT;
ALTER TABLE piece_media ADD COLUMN removed_at_utc TEXT;
CREATE TABLE piece_presentation_media (
piece_id TEXT PRIMARY KEY REFERENCES pieces(id) ON DELETE CASCADE,
media_id TEXT NOT NULL UNIQUE REFERENCES piece_media(id) ON DELETE RESTRICT
);
CREATE TRIGGER validate_piece_presentation_media_insert
BEFORE INSERT ON piece_presentation_media
WHEN NOT EXISTS (
SELECT 1 FROM piece_media
WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'presentation media does not belong to piece');
END;
CREATE TRIGGER validate_piece_presentation_media_update
BEFORE UPDATE ON piece_presentation_media
WHEN NOT EXISTS (
SELECT 1 FROM piece_media
WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'presentation media does not belong to piece');
END;
ALTER TABLE usage_topic_snapshots ADD COLUMN icon_name TEXT NOT NULL DEFAULT '';
CREATE TABLE usage_media_snapshots (
id TEXT PRIMARY KEY,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
original_media_id TEXT NOT NULL REFERENCES piece_media(id) ON DELETE RESTRICT,
alt_text TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
is_presentation INTEGER NOT NULL DEFAULT 0 CHECK (is_presentation IN (0, 1)),
UNIQUE (usage_id, position)
);
CREATE INDEX idx_pieces_deleted_at ON pieces(deleted_at_utc);
CREATE INDEX idx_piece_media_active ON piece_media(piece_id, position)
WHERE removed_at_utc IS NULL;
CREATE INDEX idx_usage_media_usage ON usage_media_snapshots(usage_id, position);
CREATE UNIQUE INDEX idx_one_presentation_media_per_usage
ON usage_media_snapshots(usage_id) WHERE is_presentation = 1;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE tools ADD COLUMN icon_name TEXT NOT NULL DEFAULT 'wrench';
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN show_overview_view TEXT NOT NULL DEFAULT 'list'
CHECK (show_overview_view IN ('list', 'calendar'));
@@ -0,0 +1,21 @@
ALTER TABLE pieces ADD COLUMN interaction_text TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN interaction_snapshot TEXT NOT NULL DEFAULT '';
CREATE TABLE live_show_timer_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(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 UNIQUE INDEX idx_one_open_show_timer_run
ON live_show_timer_runs(show_id)
WHERE ended_at_utc IS NULL;
CREATE INDEX idx_live_show_timer_runs_show
ON live_show_timer_runs(show_id, started_at_utc);
INSERT INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
FROM live_usage_runs;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS live_show_timer_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(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 UNIQUE INDEX IF NOT EXISTS idx_one_open_show_timer_run
ON live_show_timer_runs(show_id)
WHERE ended_at_utc IS NULL;
CREATE INDEX IF NOT EXISTS idx_live_show_timer_runs_show
ON live_show_timer_runs(show_id, started_at_utc);
INSERT OR IGNORE INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
FROM live_usage_runs;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.8", "version": "0.5.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.8", "version": "0.5.12",
"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.8", "version": "0.5.12",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -0,0 +1,51 @@
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const sourceRoot = process.argv[2];
if (!sourceRoot) {
throw new Error('Usage: node scripts/generate-classic-regular-icons.mjs <font-awesome-web-root>');
}
const javascript = readFileSync(path.join(sourceRoot, 'js', 'regular.js'), 'utf8');
const metadata = JSON.parse(readFileSync(path.join(sourceRoot, 'metadata', 'icons.json'), 'utf8'));
const start = javascript.indexOf(' var icons = ') + ' var icons = '.length;
const end = javascript.indexOf('\n };', start) + '\n }'.length;
if (start < ' var icons = '.length || end < start)
throw new Error('Classic Regular icons not found');
const definitions = JSON.parse(javascript.slice(start, end));
const icons = Object.keys(definitions)
.map((name) => {
const details = metadata[name];
return {
name,
label: details?.label ?? name.replaceAll('-', ' '),
terms: details?.search?.terms ?? []
};
})
.sort((left, right) => left.label.localeCompare(right.label, 'en'));
const output = `// Generated from Font Awesome Pro Plus 7.0.0 Classic Regular metadata.
// Covered by the Font Awesome Pro commercial license; see THIRD_PARTY_NOTICES.md.
export type ClassicRegularIcon = {
\tname: string;
\tlabel: string;
\tterms: readonly string[];
};
export const classicRegularIcons: readonly ClassicRegularIcon[] = ${JSON.stringify(icons)};
export type IconName = string;
export const classicRegularIconNames = new Set<string>(
classicRegularIcons.map((icon) => icon.name)
);
`;
writeFileSync('src/lib/icons/classic-regular.ts', output);
mkdirSync('static/icons', { recursive: true });
copyFileSync(
path.join(sourceRoot, 'sprites-full', 'regular.svg'),
'static/icons/classic-regular.svg'
);
console.log(`Generated ${icons.length} Classic Regular icons.`);
+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();
+2134 -334
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
berlinDateKey,
buildCalendarDays,
formatMonthLabel,
normalizeMonthKey,
shiftMonth
} from './calendar.js';
describe('show calendar', () => {
it('groups UTC instants by their Berlin civil date', () => {
expect(berlinDateKey('2026-09-30T22:30:00.000Z')).toBe('2026-10-01');
expect(berlinDateKey('2026-12-31T23:30:00.000Z')).toBe('2027-01-01');
});
it('builds a Monday-first six-week month without dropping boundary days', () => {
const days = buildCalendarDays('2026-10', '2026-10-24');
expect(days).toHaveLength(42);
expect(days[0]).toMatchObject({ date: '2026-09-28', inSelectedMonth: false });
expect(days.find((day) => day.date === '2026-10-24')?.isToday).toBe(true);
expect(days.at(-1)?.date).toBe('2026-11-08');
});
it('moves across year boundaries and validates month keys', () => {
expect(shiftMonth('2026-12', 1)).toBe('2027-01');
expect(shiftMonth('2026-01', -1)).toBe('2025-12');
expect(normalizeMonthKey('2026-13', '2026-10')).toBe('2026-10');
expect(normalizeMonthKey('2026-02', '2026-10')).toBe('2026-02');
expect(formatMonthLabel('2026-10')).toBe('Oktober 2026');
});
});
+75
View File
@@ -0,0 +1,75 @@
export const BERLIN_TIME_ZONE = 'Europe/Berlin';
export type CalendarDay = {
date: string;
dayNumber: number;
inSelectedMonth: boolean;
isToday: boolean;
};
const berlinDateFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
function padded(value: number) {
return String(value).padStart(2, '0');
}
function dateParts(value: string | Date) {
const parts = berlinDateFormatter.formatToParts(
typeof value === 'string' ? new Date(value) : value
);
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value);
return { year: read('year'), month: read('month'), day: read('day') };
}
export function berlinDateKey(value: string | Date) {
const parts = dateParts(value);
return `${parts.year}-${padded(parts.month)}-${padded(parts.day)}`;
}
export function berlinMonthKey(value: string | Date) {
return berlinDateKey(value).slice(0, 7);
}
export function normalizeMonthKey(value: string | null | undefined, fallback: string) {
if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return fallback;
return value;
}
export function shiftMonth(monthKey: string, delta: number) {
const [year, month] = monthKey.split('-').map(Number);
const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
return `${shifted.getUTCFullYear()}-${padded(shifted.getUTCMonth() + 1)}`;
}
export function formatMonthLabel(monthKey: string) {
const [year, month] = monthKey.split('-').map(Number);
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'UTC',
month: 'long',
year: 'numeric'
}).format(new Date(Date.UTC(year, month - 1, 1)));
}
export function buildCalendarDays(monthKey: string, todayKey: string): CalendarDay[] {
const [year, month] = monthKey.split('-').map(Number);
const first = new Date(Date.UTC(year, month - 1, 1));
const mondayOffset = (first.getUTCDay() + 6) % 7;
const start = new Date(Date.UTC(year, month - 1, 1 - mondayOffset));
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + index);
const dateKey = `${date.getUTCFullYear()}-${padded(date.getUTCMonth() + 1)}-${padded(date.getUTCDate())}`;
return {
date: dateKey,
dayNumber: date.getUTCDate(),
inSelectedMonth: dateKey.startsWith(monthKey),
isToday: dateKey === todayKey
};
});
}
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
</script>
<svg
width={size}
height={size}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
class="app-icon"
>
<use href={`/icons/classic-regular.svg#${name || 'circle'}`} />
</svg>
<style>
.app-icon {
display: inline-block;
flex: none;
fill: currentColor;
vertical-align: -0.125em;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import AppIcon from './AppIcon.svelte';
type Item = {
id: string;
name: string;
iconName?: string;
description?: string;
meta?: string;
};
let {
items,
selectedIds,
label,
fieldName,
onselectionchange
}: {
items: Item[];
selectedIds: string[];
label: string;
fieldName?: string;
onselectionchange: (ids: string[]) => void;
} = $props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
items.filter((item) =>
`${item.name} ${item.description ?? ''} ${item.meta ?? ''}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let selected = $derived(items.filter((item) => selectedIds.includes(item.id)));
function toggle(id: string) {
onselectionchange(
selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id]
);
}
</script>
<div class="choice-picker">
{#if fieldName}
{#each selectedIds as id (id)}<input type="hidden" name={fieldName} value={id} />{/each}
{/if}
{#if selected.length}
<div class="choice-picker-selected" aria-label={`${label}: ausgewählt`}>
{#each selected as item (item.id)}
<span
>{#if item.iconName}<AppIcon name={item.iconName} />{/if}{item.name}</span
>
{/each}
</div>
{/if}
<details bind:this={menu} class="choice-picker-menu">
<summary>{selected.length ? `${label} ändern` : `${label} auswählen`}</summary>
<div class="choice-picker-popover">
<label class="choice-picker-search">
<span class="sr-only">{label} durchsuchen</span>
<AppIcon name="magnifying-glass" />
<input type="search" placeholder={`${label} durchsuchen`} bind:value={query} />
</label>
<div class="choice-picker-options">
{#each filtered as item (item.id)}
<label class:active={selectedIds.includes(item.id)}>
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onchange={() => toggle(item.id)}
/>
{#if item.iconName}<AppIcon name={item.iconName} size="1.2em" />{/if}
<span
><strong>{item.name}</strong>{#if item.description}<small>{item.description}</small
>{/if}</span
>
{#if item.meta}<small>{item.meta}</small>{/if}
</label>
{:else}<p>Keine Treffer.</p>{/each}
</div>
<button
class="choice-picker-close text-button"
type="button"
onclick={() => (menu.open = false)}>Auswahl schließen</button
>
</div>
</details>
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { classicRegularIcons } from '#lib/icons/classic-regular.js';
import AppIcon from './AppIcon.svelte';
let { name = $bindable(), inputName = 'iconName' }: { name: string; inputName?: string } =
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let visible = $derived(filtered.slice(0, 120));
</script>
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
><span class="sr-only">Icons durchsuchen</span><input
type="search"
placeholder="Icon suchen"
bind:value={query}
/></label
>
<div class="icon-picker-grid">
{#each visible as icon (icon.name)}
<button
type="button"
class:active={name === icon.name}
title={icon.label}
onclick={() => {
name = icon.name;
menu.open = false;
}}><AppIcon name={icon.name} /><span>{icon.label}</span></button
>
{/each}
</div>
{#if filtered.length > visible.length}<p class="icon-picker-hint">
{visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu
finden.
</p>{/if}
</div>
</details>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
value = $bindable(),
form,
name = 'title',
label = 'Titel',
oncommit
}: {
value: string;
form: string;
name?: string;
label?: string;
oncommit?: () => void;
} = $props();
let editing = $state(false);
let original = $state('');
let input = $state<HTMLInputElement>();
let trigger = $state<HTMLButtonElement>();
async function edit() {
original = value;
editing = true;
await tick();
input?.focus();
input?.select();
}
async function finish(commit: boolean) {
if (!editing) return;
if (!commit) value = original;
editing = false;
if (commit && value !== original) oncommit?.();
await tick();
trigger?.focus();
}
</script>
<div class="inline-title-editor">
<input type="hidden" {form} {name} {value} />
{#if editing}
<label>
<span class="sr-only">{label}</span>
<input
bind:this={input}
bind:value
maxlength="200"
required
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
void finish(false);
}
}}
onblur={() => void finish(true)}
/>
</label>
{:else}
<button bind:this={trigger} type="button" class="inline-title-trigger" onclick={edit}>
<span>{value}</span><AppIcon name="pen" size="0.9em" />
<span class="sr-only">{label} bearbeiten</span>
</button>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let { let {
name, name,
@@ -60,12 +61,15 @@
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')} <button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button ><em>I</em></button
> >
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button> <button type="button" aria-label="Liste" title="Liste" onclick={addList}
><AppIcon name="list-check" /> Liste</button
>
<button <button
type="button" type="button"
aria-label="Link" aria-label="Link"
title="Link" title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
><AppIcon name="link" /> Link</button
> >
</span> </span>
</div> </div>
+13 -26
View File
@@ -1,34 +1,21 @@
<script lang="ts"> <script lang="ts">
import AppIcon from './AppIcon.svelte';
let { name }: { name: string } = $props(); let { name }: { name: string } = $props();
const aliases: Record<string, string> = {
library: 'folder-open',
shows: 'calendar-days',
archive: 'box-archive',
settings: 'gear',
edit: 'pen'
};
let iconName = $derived(aliases[name] ?? name);
</script> </script>
<svg viewBox="0 0 24 24" aria-hidden="true"> <AppIcon name={iconName} size="1.25rem" />
{#if name === 'inbox'}
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
{:else if name === 'library'}
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
{:else if name === 'shows'}
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
{:else if name === 'archive'}
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
{:else if name === 'settings'}
<circle cx="12" cy="12" r="3" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{:else if name === 'edit'}
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
{/if}
</svg>
<style> <style>
svg { :global(.app-icon) {
width: 1.25rem; display: block;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
} }
</style> </style>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick, type Snippet } from 'svelte'; import { tick, type Snippet } from 'svelte';
import AppIcon from './AppIcon.svelte';
let { let {
labelledby, labelledby,
@@ -53,7 +54,7 @@
class="dialog-close" class="dialog-close"
type="button" type="button"
aria-label="Editor schließen" aria-label="Editor schließen"
onclick={onrequestclose}>×</button onclick={onrequestclose}><AppIcon name="xmark" /></button
> >
</header> </header>
<nav class="piece-dialog-tabs" aria-label="Editorbereiche"> <nav class="piece-dialog-tabs" aria-label="Editorbereiche">
@@ -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>
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark'; type Preference = 'system' | 'light' | 'dark';
const preferences: Preference[] = ['system', 'light', 'dark']; const preferences: Preference[] = ['system', 'light', 'dark'];
@@ -42,8 +43,10 @@
onclick={cycle} onclick={cycle}
aria-label={`Darstellung: ${labels[preference]}`} aria-label={`Darstellung: ${labels[preference]}`}
> >
<span class="theme-symbol" aria-hidden="true"> <span class="theme-symbol" aria-hidden="true"
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'} ><AppIcon
</span> name={preference === 'system' ? 'display' : preference === 'light' ? 'sun' : 'moon'}
/></span
>
<span>{labels[preference]}</span> <span>{labels[preference]}</span>
</button> </button>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
import IconPicker from './IconPicker.svelte';
type Format = { id: string; name: string };
type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
iconName: string;
isActive: boolean;
formatIds: string[];
};
let { formats }: { formats: Format[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let tool = $state<Tool | null>(null);
let name = $state('');
let startUrl = $state('');
let description = $state('');
let iconName = $state('wrench');
let isActive = $state(true);
let formatIds = $state<string[]>([]);
export async function showModal(selectedTool: Tool | null = null) {
tool = selectedTool;
name = selectedTool?.name ?? '';
startUrl = selectedTool?.startUrl ?? '';
description = selectedTool?.description ?? '';
iconName = selectedTool?.iconName || 'wrench';
isActive = selectedTool?.isActive ?? true;
formatIds = [...(selectedTool?.formatIds ?? [])];
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog tool-editor-dialog"
aria-labelledby="tool-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name={iconName || 'wrench'} /></span>
<div>
<p class="eyebrow">{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}</p>
<h2 id="tool-dialog-title">{tool?.name || 'Neues Werkzeug'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Werkzeugdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form
class="management-dialog-form"
method="POST"
action={tool ? '?/updateTool' : '?/createTool'}
>
{#if tool}<input type="hidden" name="toolId" value={tool.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Start-URL<input
bind:value={startUrl}
name="startUrl"
type="url"
maxlength="2000"
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea bind:value={description} name="description" rows="3" maxlength="2000"
></textarea></label
>
</div>
<div class="management-dialog-selections">
<div>
<span class="field-label">Icon</span>
<IconPicker bind:name={iconName} />
</div>
<div>
<span class="field-label">Passende Formate</span>
<ChoicePicker
items={formats}
selectedIds={formatIds}
label="Formate"
fieldName="formatIds"
onselectionchange={(ids) => (formatIds = ids)}
/>
</div>
<label class="toggle-row"
><input type="checkbox" name="isActive" value="1" bind:checked={isActive} /><span
><strong>Aktiv</strong><small
>Deaktivierte Werkzeuge bleiben in bestehenden Pieces erhalten.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{tool ? 'Änderungen speichern' : 'Werkzeug anlegen'}</button
>
</footer>
</form>
</dialog>
+88 -21
View File
@@ -2,6 +2,7 @@
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 MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
@@ -25,8 +26,13 @@
let formId = $derived(`usage-editor-form-${usage.id}`); let formId = $derived(`usage-editor-form-${usage.id}`);
let editor: { showModal: () => void; close: () => void }; let editor: { showModal: () => void; close: () => void };
let activeSection = $state<'script' | 'research' | 'presentation'>('script'); let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let draft = $state(untrack(() => ({ ...usage }))); let draft = $state(
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false); let dirty = $state(false);
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
function durationInMinutes(seconds: number) { function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2)); return Number((seconds / 60).toFixed(2));
@@ -65,18 +71,7 @@
> >
{#snippet header()} {#snippet header()}
<p class="eyebrow">Sendungsfassung</p> <p class="eyebrow">Sendungsfassung</p>
<label class="piece-dialog-title"> <InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<span class="sr-only">Titel</span>
<textarea
form={formId}
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}
oninput={() => (dirty = true)}></textarea>
</label>
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2> <h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet} {/snippet}
@@ -113,6 +108,13 @@
rows={10} rows={10}
bind:value={draft.moderation} bind:value={draft.moderation}
/> />
<MarkdownEditorField
name="interaction"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interaction}
/>
<MarkdownEditorField <MarkdownEditorField
name="transition" name="transition"
label="Überleitung" label="Überleitung"
@@ -163,13 +165,40 @@
/> />
</div> </div>
<section class="piece-source-summary"> <section class="piece-source-summary">
<p class="eyebrow">Snapshot-Quelle</p> <p class="eyebrow">Snapshot-Quellen</p>
{#if draft.presentationSourceUrl} {#if draft.sources.length}
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong> {#each draft.sources as source (source.id)}<div>
<small>{draft.presentationSourceUrl}</small> <strong>{source.label}</strong><small>{source.url}</small>
{:else} </div>{/each}
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p> {:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
{/if} </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>
{#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> </section>
@@ -221,6 +250,7 @@
value={draft.presentationSourceTimecodeSeconds ?? ''} value={draft.presentationSourceTimecodeSeconds ?? ''}
/> />
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'} {#if draft.presentationMode !== 'text'}
<div class="presentation-settings"> <div class="presentation-settings">
<div class="presentation-source-summary wide-field"> <div class="presentation-source-summary wide-field">
@@ -268,6 +298,33 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if} {/if}
</div> </div>
{#if draft.media.length}<fieldset class="usage-media-picker">
<legend>Hochgeladenes Präsentationsbild</legend>
<label
><input
type="radio"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
dirty = true;
}}
/> Kein Bild</label
>
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
type="radio"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
dirty = true;
}}
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
>Auswählen</span
></label
>{/each}
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p> <p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4> <h4>{draft.outputTitle || draft.title}</h4>
@@ -275,8 +332,18 @@
<p class="preview-markdown-source"> <p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'} {draft.presentation || 'Noch kein öffentlicher Text.'}
</p> </p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else} {:else}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if} {#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p> <p>
{draft.presentationDescription || {draft.presentationDescription ||
draft.presentationSourceUrl || draft.presentationSourceUrl ||
File diff suppressed because it is too large. Load diff
+15
View File
@@ -70,6 +70,21 @@ afterEach(async () => {
}); });
describe('authentication', () => { describe('authentication', () => {
it('stores the show overview preference per user', async () => {
const { createFirstAdmin, getShowOverviewView, setShowOverviewView } =
await import('./auth.js');
const user = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'calendar@example.test',
displayName: 'Kalender Test',
password: 'a-valid-password-123'
});
expect(getShowOverviewView(user.id)).toBe('list');
expect(setShowOverviewView(user.id, 'calendar')).toBe('calendar');
expect(getShowOverviewView(user.id)).toBe('calendar');
expect(() => setShowOverviewView(user.id, 'invalid')).toThrow('INVALID_VIEW');
});
it('creates the first Barkeeper once and stores no plaintext secret', async () => { it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js'); const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort'; const password = 'Ein-sehr-langes-Testpasswort';
+18
View File
@@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = {
} as const; } as const;
export type Role = 'barkeeper' | 'thekencrew'; export type Role = 'barkeeper' | 'thekencrew';
export type ShowOverviewView = 'list' | 'calendar';
export type AuthUser = { export type AuthUser = {
id: string; id: string;
email: string; email: string;
@@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string {
return email.trim().toLowerCase(); return email.trim().toLowerCase();
} }
export function getShowOverviewView(userId: string): ShowOverviewView {
const row = getDatabase()
.prepare('SELECT show_overview_view FROM users WHERE id = ?')
.get(userId) as { show_overview_view: ShowOverviewView } | undefined;
if (!row) throw new Error('NOT_FOUND');
return row.show_overview_view;
}
export function setShowOverviewView(userId: string, view: string): ShowOverviewView {
if (view !== 'list' && view !== 'calendar') throw new Error('INVALID_VIEW');
const result = getDatabase()
.prepare('UPDATE users SET show_overview_view = ?, updated_at_utc = ? WHERE id = ?')
.run(view, nowUtc(), userId);
if (!result.changes) throw new Error('NOT_FOUND');
return view;
}
export function validEmail(email: string): boolean { export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
} }
+110 -4
View File
@@ -18,10 +18,13 @@ import {
savePiece, savePiece,
savePieceAsTemplate, savePieceAsTemplate,
setPieceArchived, setPieceArchived,
setPieceDeleted,
setPieceFavorite,
uploadPieceImage, uploadPieceImage,
updateTaxonomy,
type PieceInput type PieceInput
} from './content.js'; } from './content.js';
import { closeDatabase } from './database.js'; import { closeDatabase, getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
let directory: string; let directory: string;
@@ -76,6 +79,18 @@ 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', () => { it('persists independently versioned templates and copies their complete snapshot', () => {
const sourceId = createPiece('talk'); const sourceId = createPiece('talk');
const source = getPiece(sourceId)!; const source = getPiece(sourceId)!;
@@ -185,8 +200,17 @@ describe('content management', () => {
}); });
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => { it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte'); createTaxonomy('topic', 'Technikgeschichte', 'microchip');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!; const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
expect(topic.iconName).toBe('microchip');
updateTaxonomy('topic', topic.id, 'Computergeschichte', 'microchip');
expect(getTaxonomy().topics.find((entry) => entry.id === topic.id)).toMatchObject({
name: 'Computergeschichte',
iconName: 'microchip'
});
expect(() => updateTaxonomy('topic', topic.id, 'Computergeschichte', '../unsafe')).toThrow(
'INVALID_ICON'
);
const sourceId = createPiece('find'); const sourceId = createPiece('find');
const source = getPiece(sourceId)!; const source = getPiece(sourceId)!;
savePiece( savePiece(
@@ -240,15 +264,17 @@ describe('content management', () => {
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
); );
await uploadPieceImage( const uploaded = await uploadPieceImage(
id, id,
1, 1,
new File([png], '../bild.png', { type: 'text/html' }), new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel' 'Ein heller Pixel',
true
); );
const piece = getPiece(id)!; const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1); expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' }); expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
expect(piece.presentationMediaId).toBe(uploaded.id);
const media = await getMediaFile(piece.media[0].id); const media = await getMediaFile(piece.media[0].id);
expect(media?.data.equals(png)).toBe(true); expect(media?.data.equals(png)).toBe(true);
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false); expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
@@ -261,7 +287,87 @@ describe('content management', () => {
'' ''
) )
).rejects.toThrow('INVALID_IMAGE_TYPE'); ).rejects.toThrow('INVALID_IMAGE_TYPE');
const stored = getDatabase()
.prepare('SELECT storage_name FROM piece_media WHERE id = ?')
.get(piece.media[0].id) as { storage_name: string };
await deletePieceImage(id, piece.media[0].id, piece.revision); await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0); expect(getPiece(id)?.media).toHaveLength(0);
expect(getPiece(id)?.presentationMediaId).toBeNull();
expect(await getMediaFile(piece.media[0].id)).toBeNull();
expect(existsSync(path.join(directory, 'uploads', stored.storage_name))).toBe(true);
});
it('selects presentation media atomically, copies active media and omits media from templates', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const selected = await uploadPieceImage(
id,
1,
new File([png], 'auswahl.png', { type: 'image/png' }),
'Auswahl',
true
);
const second = await uploadPieceImage(
id,
selected.revision,
new File([png], 'entfernt.png', { type: 'image/png' }),
'Entfernt'
);
await deletePieceImage(id, second.id, second.revision);
const source = getPiece(id)!;
expect(() =>
savePiece(id, source.revision, {
...source,
title: 'Darf nicht gespeichert werden',
status: 'preparing',
presentationMediaId: 'fremdes-medium'
})
).toThrow('presentation media does not belong to piece');
expect(getPiece(id)).toMatchObject({
title: 'Neues Piece',
revision: source.revision,
presentationMediaId: selected.id
});
const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id);
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
.get(template.id) as { snapshot_json: string };
expect(JSON.parse(snapshot.snapshot_json)).not.toHaveProperty('presentationMediaId');
expect(getPiece(createPiece(template.id))?.presentationMediaId).toBeNull();
});
it('soft-deletes pieces behind revisions and keeps their original media private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const uploaded = await uploadPieceImage(
id,
1,
new File([png], 'privat.png', { type: 'image/png' }),
'Privat'
);
const deleted = setPieceDeleted(id, uploaded.revision, true);
expect(getPiece(id)).toBeNull();
expect(listPieces({ statuses: ['preparing'] }).some((piece) => piece.id === id)).toBe(false);
expect(await getMediaFile(uploaded.id)).toBeNull();
expect(() => setPieceFavorite(id, true)).toThrow('NOT_FOUND');
expect(() => savePiece(id, deleted.revision, completeInput())).toThrow('NOT_FOUND');
expect(() => setPieceDeleted(id, uploaded.revision, false)).toThrow('CONFLICT');
const restored = setPieceDeleted(id, deleted.revision, false);
expect(getPiece(id)).toMatchObject({ id, revision: restored.revision });
expect((await getMediaFile(uploaded.id))?.data.equals(png)).toBe(true);
}); });
}); });
+320 -90
View File
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { getServerPaths } from './config.js'; import { getServerPaths } from './config.js';
import { import {
@@ -21,6 +22,20 @@ 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;
iconName: string;
isActive: boolean;
};
export type PieceInput = { export type PieceInput = {
title: string; title: string;
outputTitle: string; outputTitle: string;
@@ -31,6 +46,7 @@ export type PieceInput = {
intro: string; intro: string;
researchText: string; researchText: string;
moderationNotes: string; moderationNotes: string;
interactionText?: string;
transitionText: string; transitionText: string;
presentationText: string; presentationText: string;
presentationMode: PresentationMode; presentationMode: PresentationMode;
@@ -45,9 +61,11 @@ export type PieceInput = {
presentationDescriptionManual: boolean; presentationDescriptionManual: boolean;
presentationImageManual: boolean; presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null; presentationMetadataFetchedAtUtc: string | null;
presentationMediaId?: string | null;
topicIds: string[]; topicIds: string[];
categoryIds: string[]; categoryIds: string[];
sources: PieceSourceInput[]; sources: PieceSourceInput[];
tools?: PieceToolInput[];
}; };
export type PieceListItem = { export type PieceListItem = {
@@ -60,20 +78,30 @@ export type PieceListItem = {
formatId: string | null; formatId: string | null;
format: string; format: string;
categories: { id: string; name: string; color: string }[]; categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[]; topics: { id: string; name: string; iconName: 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' | 'presentationMediaId' | 'interactionText'
> & {
id: string; id: string;
status: PieceStatus; status: PieceStatus;
revision: number; revision: number;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: string; updatedAtUtc: string;
media: PieceMedia[]; media: PieceMedia[];
presentationMediaId: string | null;
isFavorite: boolean;
interactionText: string;
tools: PieceTool[];
}; };
type ValidatedPieceInput = PieceInput;
export type PieceMedia = { export type PieceMedia = {
id: string; id: string;
originalName: string; originalName: string;
@@ -86,7 +114,7 @@ export type PieceMedia = {
export type Taxonomy = { export type Taxonomy = {
categories: { id: string; name: string; color: string }[]; categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[]; formats: { id: string; name: string }[];
topics: { id: string; name: string }[]; topics: { id: string; name: string; iconName: string }[];
}; };
export type PieceTemplate = { export type PieceTemplate = {
@@ -94,6 +122,7 @@ export type PieceTemplate = {
name: string; name: string;
description: string; description: string;
revision: number; revision: number;
iconName: string;
}; };
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
@@ -114,6 +143,13 @@ function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
function cleanIconName(value: unknown): string {
const iconName = cleanText(value, 80, 'icon');
if (!iconName) return '';
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
}
function validUrl(value: string): boolean { function validUrl(value: string): boolean {
try { try {
const url = new URL(value); const url = new URL(value);
@@ -123,6 +159,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(', ');
@@ -132,7 +194,7 @@ function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories',
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY'); if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
} }
function validatePieceInput(input: PieceInput): PieceInput { function validatePieceInput(input: PieceInput): ValidatedPieceInput {
const title = cleanText(input.title, 200, 'title'); const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE'); if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS'); if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
@@ -183,6 +245,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
} }
const detected = const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
const presentationMediaId =
input.presentationMediaId === undefined
? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
return { return {
title, title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'), outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
@@ -193,6 +259,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'), researchText: cleanText(input.researchText, 50_000, 'research'),
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'), moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
interactionText:
input.interactionText === undefined
? undefined
: cleanText(input.interactionText, 50_000, 'interaction'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'), transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'), presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
presentationMode: input.presentationMode, presentationMode: input.presentationMode,
@@ -211,9 +281,11 @@ function validatePieceInput(input: PieceInput): PieceInput {
presentationDescriptionManual: Boolean(input.presentationDescriptionManual), presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual), presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null, presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
presentationMediaId,
topicIds, topicIds,
categoryIds, categoryIds,
sources sources,
tools: cleanPieceTools(input.tools)
}; };
} }
@@ -227,7 +299,7 @@ export function getTaxonomy(): Taxonomy {
.prepare('SELECT id, name FROM formats ORDER BY name') .prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'], .all() as Taxonomy['formats'],
topics: database topics: database
.prepare('SELECT id, name FROM topics ORDER BY name') .prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as Taxonomy['topics'] .all() as Taxonomy['topics']
}; };
} }
@@ -244,6 +316,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: {
@@ -252,9 +325,13 @@ export function listPieces(filters: {
formatId?: string; formatId?: string;
topicId?: string; topicId?: string;
categoryId?: string; categoryId?: string;
deleted?: boolean;
}): PieceListItem[] { }): PieceListItem[] {
if (!filters.statuses.length) return []; if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`]; const conditions = [
`p.status IN (${filters.statuses.map(() => '?').join(', ')})`,
filters.deleted ? 'p.deleted_at_utc IS NOT NULL' : 'p.deleted_at_utc IS NULL'
];
const parameters: unknown[] = [...filters.statuses]; const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase(); const query = filters.query?.trim().toLowerCase();
if (query) { if (query) {
@@ -288,13 +365,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 AND pm.removed_at_utc IS NULL 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[];
@@ -304,7 +382,7 @@ export function listPieces(filters: {
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name` WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
); );
const topicStatement = database.prepare( const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id `SELECT t.id, t.name, t.icon_name AS iconName FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name` WHERE pt.piece_id = ? ORDER BY t.name`
); );
return rows.map((row) => ({ return rows.map((row) => ({
@@ -319,7 +397,8 @@ 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
})); }));
} }
@@ -334,6 +413,7 @@ type PieceRow = {
intro: string; intro: string;
research_text: string; research_text: string;
moderation_notes: string; moderation_notes: string;
interaction_text: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode; presentation_mode: PresentationMode;
@@ -351,6 +431,8 @@ 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;
presentation_media_id: string | null;
}; };
export function getPiece(id: string): PieceEditor | null { export function getPiece(id: string): PieceEditor | null {
@@ -358,13 +440,16 @@ export function getPiece(id: string): PieceEditor | null {
const row = database const row = database
.prepare( .prepare(
`SELECT id, title, output_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, interaction_text, 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 (SELECT ppm.media_id FROM piece_presentation_media ppm
FROM pieces WHERE id = ?` JOIN piece_media pm ON pm.id = ppm.media_id AND pm.removed_at_utc IS NULL
WHERE ppm.piece_id = pieces.id) AS presentation_media_id,
revision, created_at_utc, updated_at_utc, is_favorite
FROM pieces WHERE id = ? AND deleted_at_utc IS NULL`
) )
.get(id) as PieceRow | undefined; .get(id) as PieceRow | undefined;
if (!row) return null; if (!row) return null;
@@ -379,6 +464,7 @@ export function getPiece(id: string): PieceEditor | null {
intro: row.intro, intro: row.intro,
researchText: row.research_text, researchText: row.research_text,
moderationNotes: row.moderation_notes, moderationNotes: row.moderation_notes,
interactionText: row.interaction_text,
transitionText: row.transition_text, transitionText: row.transition_text,
presentationText: row.presentation_text, presentationText: row.presentation_text,
presentationMode: row.presentation_mode, presentationMode: row.presentation_mode,
@@ -393,9 +479,11 @@ export function getPiece(id: string): PieceEditor | null {
presentationDescriptionManual: row.presentation_description_manual === 1, presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1, presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc, presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
presentationMediaId: row.presentation_media_id,
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;
@@ -421,7 +509,7 @@ export function getPiece(id: string): PieceEditor | null {
database database
.prepare( .prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position `SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position` FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL ORDER BY position`
) )
.all(id) as { .all(id) as {
id: string; id: string;
@@ -438,6 +526,33 @@ 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.icon_name, 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;
icon_name: 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,
iconName: tool.icon_name,
isActive: tool.is_active === 1
})) }))
}; };
} }
@@ -460,6 +575,32 @@ 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)
);
}
function replacePresentationMedia(pieceId: string, mediaId: string | null | undefined) {
if (mediaId === undefined) return;
const database = getDatabase();
database.prepare('DELETE FROM piece_presentation_media WHERE piece_id = ?').run(pieceId);
if (mediaId) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(pieceId, mediaId);
}
}
export function setPieceFavorite(id: string, favorite: boolean) {
const result = getDatabase()
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ? AND deleted_at_utc IS NULL')
.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) {
@@ -472,14 +613,14 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const result = database const result = database
.prepare( .prepare(
`UPDATE pieces SET title = ?, output_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 = ?, interaction_text = COALESCE(?, interaction_text), 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 = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?, presentation_description_manual = ?, presentation_image_manual = ?,
presentation_metadata_fetched_at_utc = ?, presentation_metadata_fetched_at_utc = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run( .run(
input.title, input.title,
@@ -491,6 +632,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.intro, input.intro,
input.researchText, input.researchText,
input.moderationNotes, input.moderationNotes,
input.interactionText ?? null,
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
input.presentationMode, input.presentationMode,
@@ -510,16 +652,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
expectedRevision expectedRevision
); );
if (result.changes !== 1) { if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id); const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
} }
replaceRelations(id, input); replaceRelations(id, input);
replacePresentationMedia(id, input.presentationMediaId);
}); });
save(); save();
return { revision: expectedRevision + 1, updatedAtUtc }; return { revision: expectedRevision + 1, updatedAtUtc };
} }
function blankPieceInput(): PieceInput { function blankPieceInput(): ValidatedPieceInput {
return { return {
title: 'Neues Piece', title: 'Neues Piece',
outputTitle: '', outputTitle: '',
@@ -530,6 +675,7 @@ function blankPieceInput(): PieceInput {
intro: '', intro: '',
researchText: '', researchText: '',
moderationNotes: '', moderationNotes: '',
interactionText: '',
transitionText: '', transitionText: '',
presentationText: '', presentationText: '',
presentationMode: 'text', presentationMode: 'text',
@@ -544,13 +690,14 @@ function blankPieceInput(): PieceInput {
presentationDescriptionManual: false, presentationDescriptionManual: false,
presentationImageManual: false, presentationImageManual: false,
presentationMetadataFetchedAtUtc: null, presentationMetadataFetchedAtUtc: null,
presentationMediaId: null,
topicIds: [], topicIds: [],
categoryIds: [], categoryIds: [],
sources: [] sources: []
}; };
} }
function templateInput(templateId: string): PieceInput { function templateInput(templateId: string): ValidatedPieceInput {
if (templateId === 'blank') return blankPieceInput(); if (templateId === 'blank') return blankPieceInput();
const row = getDatabase() const row = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?') .prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
@@ -558,6 +705,7 @@ function templateInput(templateId: string): PieceInput {
if (!row) throw new Error('INVALID_TEMPLATE'); if (!row) throw new Error('INVALID_TEMPLATE');
try { try {
const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>; const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>;
delete snapshot.presentationMediaId;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' }); return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch { } catch {
throw new Error('INVALID_TEMPLATE'); throw new Error('INVALID_TEMPLATE');
@@ -565,22 +713,33 @@ function templateInput(templateId: string): PieceInput {
} }
export function listPieceTemplates(): PieceTemplate[] { export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase() const database = getDatabase();
const rows = database
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name') .prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[]; .all() as Omit<PieceTemplate, 'iconName'>[];
const iconForTemplate = (templateId: string) => {
const input = templateInput(templateId);
if (!input.topicIds.length) return 'newspaper';
return (
(
database.prepare('SELECT icon_name FROM topics WHERE id = ?').get(input.topicIds[0]) as
{ icon_name: string } | undefined
)?.icon_name || 'newspaper'
);
};
return [ return [
{ {
id: 'blank', id: 'blank',
name: 'Leer starten', name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.', description: 'Ein freies Piece ohne Vorgaben.',
revision: 0 revision: 0,
iconName: 'newspaper'
}, },
...rows.filter((template) => { ...rows.flatMap((template) => {
try { try {
templateInput(template.id); return [{ ...template, iconName: iconForTemplate(template.id) }];
return true;
} catch { } catch {
return false; return [];
} }
}) })
]; ];
@@ -595,8 +754,8 @@ export function createPiece(template = 'blank'): string {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, output_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, interaction_text, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
@@ -609,6 +768,7 @@ export function createPiece(template = 'blank'): string {
input.intro, input.intro,
input.researchText, input.researchText,
input.moderationNotes, input.moderationNotes,
input.interactionText ?? '',
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
now, now,
@@ -637,6 +797,7 @@ export function savePieceAsTemplate(
delete snapshot.createdAtUtc; delete snapshot.createdAtUtc;
delete snapshot.updatedAtUtc; delete snapshot.updatedAtUtc;
delete snapshot.media; delete snapshot.media;
delete snapshot.presentationMediaId;
snapshot.status = 'preparing'; snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot); const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase(); const database = getDatabase();
@@ -698,18 +859,22 @@ export function setPieceArchived(id: string, expectedRevision: number, archived:
? database ? database
.prepare( .prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1, `UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'` updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'
AND deleted_at_utc IS NULL`
) )
.run(now, id, expectedRevision) .run(now, id, expectedRevision)
: database : database
.prepare( .prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL, `UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ? revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'` WHERE id = ? AND revision = ? AND status = 'archived'
AND deleted_at_utc IS NULL`
) )
.run(now, id, expectedRevision); .run(now, id, expectedRevision);
if (result.changes !== 1) { if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id); const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
} }
} }
@@ -723,7 +888,11 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 }); await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase(); const database = getDatabase();
const storedMedia = database const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position') .prepare(
`SELECT pm.*, ppm.media_id IS NOT NULL AS is_presentation
FROM piece_media pm LEFT JOIN piece_presentation_media ppm ON ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(sourceId) as { .all(sourceId) as {
id: string; id: string;
storage_name: string; storage_name: string;
@@ -732,8 +901,9 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
byte_size: number; byte_size: number;
alt_text: string; alt_text: string;
position: number; position: number;
is_presentation: number;
}[]; }[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = []; const copied: { id: string; source: (typeof storedMedia)[number]; storageName: string }[] = [];
try { try {
for (const media of storedMedia) { for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!; const extension = ALLOWED_IMAGES.get(media.mime_type)!;
@@ -742,19 +912,22 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
path.join(uploadDirectory, media.storage_name), path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName) path.join(uploadDirectory, storageName)
); );
copied.push({ source: media, storageName }); copied.push({ id: randomUUID(), source: media, storageName });
} }
database.transaction(() => { database.transaction(() => {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, output_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, interaction_text, 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,
@@ -766,6 +939,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.intro, source.intro,
source.researchText, source.researchText,
source.moderationNotes, source.moderationNotes,
source.interactionText,
source.transitionText, source.transitionText,
source.presentationText, source.presentationText,
source.presentationMode, source.presentationMode,
@@ -790,7 +964,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
); );
for (const media of copied) { for (const media of copied) {
addMedia.run( addMedia.run(
randomUUID(), media.id,
id, id,
media.storageName, media.storageName,
media.source.original_name, media.source.original_name,
@@ -801,6 +975,12 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
now now
); );
} }
const presentation = copied.find((media) => media.source.is_presentation === 1);
if (presentation) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(id, presentation.id);
}
})(); })();
return id; return id;
} catch (error) { } catch (error) {
@@ -827,7 +1007,7 @@ export async function loadPiecePresentationMetadata(
p.presentation_image_manual, s.url p.presentation_image_manual, s.url
FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id
AND s.position = p.presentation_source_position AND s.position = p.presentation_source_position
WHERE p.id = ? AND p.status != 'archived'` WHERE p.id = ? AND p.status != 'archived' AND p.deleted_at_utc IS NULL`
) )
.get(id) as .get(id) as
| { | {
@@ -853,7 +1033,7 @@ export async function loadPiecePresentationMetadata(
presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END, presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END,
presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?,
presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1 presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run( .run(
metadata.title, metadata.title,
@@ -875,7 +1055,8 @@ export async function uploadPieceImage(
pieceId: string, pieceId: string,
expectedRevision: number, expectedRevision: number,
file: File, file: File,
altTextValue: string altTextValue: string,
selectForPresentation = false
) { ) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE'); if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@@ -889,85 +1070,86 @@ export async function uploadPieceImage(
const filePath = path.join(uploadDirectory, storageName); const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 }); await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase(); const database = getDatabase();
const mediaId = randomUUID();
const now = new Date().toISOString();
try { try {
database.transaction(() => { database.transaction(() => {
const updated = database const updated = database
.prepare( .prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ? `UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run(new Date().toISOString(), pieceId, expectedRevision); .run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT'); if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?') .prepare(
`SELECT count(*) AS count
FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL`
)
.get(pieceId) as { count: number }; .get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES'); if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
const position = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS next FROM piece_media WHERE piece_id = ?'
)
.get(pieceId) as { next: number }
).next;
database database
.prepare( .prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type, `INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
randomUUID(), mediaId,
pieceId, pieceId,
storageName, storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`, path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime, detected.mime,
buffer.byteLength, buffer.byteLength,
altText, altText,
count.count, position,
new Date().toISOString() now
); );
if (selectForPresentation) replacePresentationMedia(pieceId, mediaId);
})(); })();
} catch (error) { } catch (error) {
await unlink(filePath).catch(() => {}); await unlink(filePath).catch(() => {});
throw error; throw error;
} }
return { id: mediaId, revision: expectedRevision + 1, updatedAtUtc: now };
} }
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) { export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase(); const database = getDatabase();
const media = database const now = new Date().toISOString();
.prepare('SELECT storage_name FROM piece_media WHERE id = ? AND piece_id = ?') database.transaction(() => {
.get(mediaId, pieceId) as { storage_name: string } | undefined; const updated = database
if (!media) throw new Error('NOT_FOUND'); .prepare(
const originalPath = path.join(getServerPaths().uploads, media.storage_name); `UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
const quarantinePath = `${originalPath}.deleting-${randomUUID()}`; WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
await rename(originalPath, quarantinePath); )
try { .run(now, pieceId, expectedRevision);
database.transaction(() => { if (updated.changes !== 1) throw new Error('CONFLICT');
const updated = database const removed = database
.prepare( .prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ? `UPDATE piece_media SET removed_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND piece_id = ? AND removed_at_utc IS NULL`
) )
.run(new Date().toISOString(), pieceId, expectedRevision); .run(now, mediaId, pieceId);
if (updated.changes !== 1) throw new Error('CONFLICT'); if (removed.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId); database.prepare('DELETE FROM piece_presentation_media WHERE media_id = ?').run(mediaId);
})(); })();
} catch (error) { return { revision: expectedRevision + 1, updatedAtUtc: now };
await rename(quarantinePath, originalPath).catch((restoreError) => {
console.error(
'Bilddatei konnte nach fehlgeschlagener Löschung nicht wiederhergestellt werden.',
restoreError
);
});
throw error;
}
try {
await unlink(quarantinePath);
} catch (error) {
console.error(
'Gelöschte Bilddatei konnte nicht aus dem Quarantänepfad entfernt werden.',
error
);
throw new Error('IMAGE_CLEANUP_FAILED', { cause: error });
}
} }
export async function getMediaFile(id: string) { export async function getMediaFile(id: string) {
const row = getDatabase() const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?') .prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, pm.alt_text
FROM piece_media pm JOIN pieces p ON p.id = pm.piece_id
WHERE pm.id = ? AND pm.removed_at_utc IS NULL AND p.deleted_at_utc IS NULL`
)
.get(id) as .get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined; { storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null; if (!row) return null;
@@ -979,7 +1161,12 @@ export async function getMediaFile(id: string) {
}; };
} }
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) { export function createTaxonomy(
kind: TaxonomyKind,
nameValue: string,
colorOrIconValue?: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy'); const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY'); if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase(); const database = getDatabase();
@@ -992,7 +1179,7 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
if (duplicate) throw new Error('TAXONOMY_EXISTS'); if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID(); const id = randomUUID();
if (kind === 'category') { if (kind === 'category') {
const color = String(colorValue ?? ''); const color = String(colorOrIconValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR'); if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories') .prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
@@ -1002,10 +1189,53 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
database database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next); .run(id, name, color, order.next);
} else if (kind === 'topic') {
database
.prepare('INSERT INTO topics (id, name, icon_name) VALUES (?, ?, ?)')
.run(id, name, cleanIconName(iconNameValue ?? colorOrIconValue));
} else { } else {
const table = kind === 'format' ? 'formats' : 'topics'; database.prepare('INSERT INTO formats (id, name) VALUES (?, ?)').run(id, name);
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
} }
return id;
}
export function updateTaxonomy(
kind: TaxonomyKind,
id: string,
nameValue: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
const database = getDatabase();
const duplicate = database
.prepare(`SELECT 1 FROM ${table} WHERE lower(name) = lower(?) AND id != ?`)
.get(name, id);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const result =
kind === 'topic'
? database
.prepare('UPDATE topics SET name = ?, icon_name = ? WHERE id = ?')
.run(name, cleanIconName(iconNameValue), id)
: database.prepare(`UPDATE ${table} SET name = ? WHERE id = ?`).run(name, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function setPieceDeleted(id: string, expectedRevision: number, deleted: boolean) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`UPDATE pieces SET deleted_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND deleted_at_utc IS ${deleted ? 'NULL' : 'NOT NULL'}`
)
.run(deleted ? now : null, now, id, expectedRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
} }
export function deleteTaxonomy(kind: TaxonomyKind, id: string) { export function deleteTaxonomy(kind: TaxonomyKind, id: string) {
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 8, migrations: 12,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+54
View File
@@ -40,6 +40,48 @@ 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', () => { it('selects any rundown usage by stable id, including the first and completed entries', () => {
let live = getLiveView('show-october'); let live = getLiveView('show-october');
expect(() => expect(() =>
@@ -161,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 });
@@ -171,6 +221,7 @@ 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',
topics: ['Gaming'],
images: [], images: [],
sources: [] sources: []
}); });
@@ -180,6 +231,9 @@ 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).toBeNull(); expect(media).toBeNull();
+548 -13
View File
@@ -1,5 +1,8 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { getServerPaths } from './config.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';
@@ -8,6 +11,7 @@ import {
movePieceUsage, movePieceUsage,
type MovePieceUsageInput, type MovePieceUsageInput,
type PieceUsage, type PieceUsage,
type ShowStatus,
type ShowSection type ShowSection
} from './shows.js'; } from './shows.js';
@@ -38,6 +42,7 @@ export type LiveUsage = PieceUsage & {
introHtml: string; introHtml: string;
researchHtml: string; researchHtml: string;
moderationHtml: string; moderationHtml: string;
interactionHtml: string;
transitionHtml: string; transitionHtml: string;
}; };
@@ -52,7 +57,10 @@ export type LiveView = {
totalDurationSeconds: number; totalDurationSeconds: number;
}; };
liveRevision: number; liveRevision: number;
calculatedAtUtc: string;
startedAtUtc: string | null; startedAtUtc: string | null;
endedAtUtc: string | null;
isPaused: boolean;
updatedAtUtc: string; updatedAtUtc: string;
presentationUsageId: string | null; presentationUsageId: string | null;
presentationRevision: number; presentationRevision: number;
@@ -62,12 +70,29 @@ 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;
};
totalTimer: {
elapsedSeconds: number;
runningSinceUtc: string | null;
};
remainingEstimate: {
seconds: number;
activeCountdownSeconds: number;
unplannedSectionCount: number;
};
}; };
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'];
@@ -141,6 +166,128 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
.run(now, showId); .run(now, showId);
} }
function closeShowTimerRun(database: Database.Database, showId: string, now: string) {
database
.prepare(
`UPDATE live_show_timer_runs SET ended_at_utc = ?
WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, showId);
}
function openShowTimerRun(database: Database.Database, showId: string, now: string) {
const active = database
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId);
if (!active) {
database
.prepare(
`INSERT INTO live_show_timer_runs (id, show_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), showId, now);
}
}
function showTimerRunning(database: Database.Database, showId: string) {
return Boolean(
database
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId)
);
}
function elapsedRuns(
runs: { started_at_utc: string; ended_at_utc: string | null }[],
now: string
) {
return 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
);
}
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 resumeSectionTimerRun(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;
const running = database
.prepare(
'SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL'
)
.get(timer.id);
if (!running) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timer.id, now);
}
}
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
@@ -164,6 +311,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(
@@ -172,7 +320,8 @@ function executeCommand(
expectedRevision: number, expectedRevision: number,
action: string, action: string,
operation: (database: Database.Database, state: LiveStateRow, now: string) => void, operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
nowValue?: Date nowValue?: Date,
allowCompleted = false
) { ) {
requireCommandId(commandId); requireCommandId(commandId);
const database = getDatabase(); const database = getDatabase();
@@ -186,7 +335,7 @@ function executeCommand(
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as { const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string; status: string;
}; };
if (show.status === 'completed') throw new Error('SHOW_COMPLETED'); if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT'); if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now); operation(database, state, now);
const updated = database const updated = database
@@ -242,6 +391,7 @@ function liveUsage(
introHtml: renderMarkdown(usage.intro), introHtml: renderMarkdown(usage.intro),
researchHtml: renderMarkdown(usage.research), researchHtml: renderMarkdown(usage.research),
moderationHtml: renderMarkdown(usage.moderation), moderationHtml: renderMarkdown(usage.moderation),
interactionHtml: renderMarkdown(usage.interaction),
transitionHtml: renderMarkdown(usage.transition) transitionHtml: renderMarkdown(usage.transition)
}; };
} }
@@ -268,6 +418,77 @@ 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 = elapsedRuns(runs, now);
sectionTimer = {
sectionId: timer.section_id,
sectionName: timer.section_name,
plannedDurationSeconds: timer.planned_duration_seconds,
elapsedSeconds,
runningSinceUtc: running?.started_at_utc ?? null
};
}
const totalTimerRuns = database
.prepare(
`SELECT started_at_utc, ended_at_utc FROM live_show_timer_runs
WHERE show_id = ? ORDER BY started_at_utc`
)
.all(showId) as { started_at_utc: string; ended_at_utc: string | null }[];
const runningTotalTimer = totalTimerRuns.find((run) => run.ended_at_utc === null) ?? null;
const totalElapsedSeconds = elapsedRuns(totalTimerRuns, now);
const remainingAnchor = current ?? next;
let reachedRemaining = !remainingAnchor;
let remainingSeconds = 0;
let activeCountdownSeconds = 0;
let unplannedSectionCount = 0;
for (const section of sections) {
if (section.id === remainingAnchor?.sectionId) reachedRemaining = true;
if (!reachedRemaining) continue;
const remainingUsages = section.usages.filter(
(usage) => usage.status === 'open' || usage.status === 'current'
);
if (remainingUsages.length === 0) continue;
if (section.id === current?.sectionId) {
if (section.durationSeconds > 0 && sectionTimer?.sectionId === section.id) {
activeCountdownSeconds = Math.max(0, section.durationSeconds - sectionTimer.elapsedSeconds);
remainingSeconds += activeCountdownSeconds;
} else {
remainingSeconds += remainingUsages.reduce((total, usage) => {
const elapsed = usage.id === current?.id ? usage.actualDurationSeconds : 0;
const remainder = Math.max(0, usage.durationSeconds - elapsed);
if (usage.id === current?.id) activeCountdownSeconds = remainder;
return total + remainder;
}, 0);
if (section.durationSeconds <= 0) unplannedSectionCount += 1;
}
} else if (section.durationSeconds > 0) {
remainingSeconds += section.durationSeconds;
} else {
remainingSeconds += remainingUsages.reduce(
(total, usage) => total + usage.durationSeconds,
0
);
unplannedSectionCount += 1;
}
}
return { return {
show: { show: {
id: show.id, id: show.id,
@@ -277,7 +498,10 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
totalDurationSeconds: show.totalDurationSeconds totalDurationSeconds: show.totalDurationSeconds
}, },
liveRevision: state.revision, liveRevision: state.revision,
calculatedAtUtc: now,
startedAtUtc: state.started_at_utc, startedAtUtc: state.started_at_utc,
endedAtUtc: state.ended_at_utc,
isPaused: Boolean(state.started_at_utc && !state.ended_at_utc && !runningTotalTimer),
updatedAtUtc: state.updated_at_utc, updatedAtUtc: state.updated_at_utc,
presentationUsageId: state.presentation_usage_id, presentationUsageId: state.presentation_usage_id,
presentationRevision: state.presentation_revision, presentationRevision: state.presentation_revision,
@@ -287,7 +511,17 @@ 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,
totalTimer: {
elapsedSeconds: totalElapsedSeconds,
runningSinceUtc: runningTotalTimer?.started_at_utc ?? null
},
remainingEstimate: {
seconds: remainingSeconds,
activeCountdownSeconds,
unplannedSectionCount
}
}; };
} }
@@ -305,7 +539,7 @@ export function startOrResumeLive(
(database, state, now) => { (database, state, now) => {
database database
.prepare( .prepare(
`UPDATE shows SET status = 'live', updated_at_utc = ? `UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND status != 'completed'` WHERE id = ? AND status != 'completed'`
) )
.run(now, showId); .run(now, showId);
@@ -315,10 +549,14 @@ export function startOrResumeLive(
WHERE show_id = ?` WHERE show_id = ?`
) )
.run(now, showId); .run(now, showId);
openShowTimerRun(database, showId, now);
const activeRun = database const activeRun = database
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL') .prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId); .get(showId);
if (state.current_usage_id && activeRun) return; if (state.current_usage_id && activeRun) {
resumeSectionTimerRun(database, showId, now);
return;
}
const current = state.current_usage_id const current = state.current_usage_id
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id) ? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
: undefined; : undefined;
@@ -330,6 +568,263 @@ export function startOrResumeLive(
); );
} }
export function pauseLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'pause',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (!showTimerRunning(database, showId)) throw new Error('ALREADY_PAUSED');
closeShowTimerRun(database, showId, now);
closeActiveRun(database, showId, now);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
) AND ended_at_utc IS NULL`
)
.run(now, showId);
},
nowValue
);
}
export function resumeLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (showTimerRunning(database, showId)) throw new Error('ALREADY_RUNNING');
openShowTimerRun(database, showId, now);
if (state.current_usage_id) {
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, state.current_usage_id, now);
}
resumeSectionTimerRun(database, showId, now);
},
nowValue
);
}
export function resetSectionTimer(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'reset-section-timer',
(database, _state, now) => {
const timer = database
.prepare(
`SELECT section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
)
.get(showId) as
| { section_id: string; planned_duration_seconds: number }
| undefined;
if (!timer) throw new Error('NO_SECTION_TIMER');
const running = showTimerRunning(database, showId);
closeSectionTimer(database, showId, now);
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, timer.section_id, timer.planned_duration_seconds, now);
if (running) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timerId, now);
}
},
nowValue
);
}
export function resetTotalTimer(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'reset-total-timer',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
const running = showTimerRunning(database, showId);
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
if (running) openShowTimerRun(database, showId, now);
},
nowValue
);
}
export function setShowStatus(
showId: string,
commandId: string,
expectedRevision: number,
expectedShowRevision: number,
status: Extract<ShowStatus, 'draft' | 'ready'>,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
`status-${status}`,
(database, state, now) => {
if (state.started_at_utc) throw new Error('ALREADY_STARTED');
const result = database
.prepare(
'UPDATE shows SET status = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?'
)
.run(status, now, showId, expectedShowRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
},
nowValue
);
}
export function endLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'end',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
closeShowTimerRun(database, showId, now);
closeActiveRun(database, showId, now);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
) AND ended_at_utc IS NULL`
)
.run(now, showId);
database
.prepare(
`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`
)
.run(now, showId);
database
.prepare(`UPDATE shows SET status = 'completed', revision = revision + 1, updated_at_utc = ? WHERE id = ?`)
.run(now, showId);
},
nowValue
);
}
export function resumeEndedLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume-ended',
(database, state, now) => {
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId);
database
.prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?')
.run(showId);
openShowTimerRun(database, showId, now);
if (state.current_usage_id) {
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, state.current_usage_id, now);
resumeSectionTimerRun(database, showId, now);
}
},
nowValue,
true
);
}
export function startNewLiveRun(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'start-new-run',
(database, state, now) => {
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
database.prepare('DELETE FROM live_usage_runs WHERE show_id = ?').run(showId);
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
database.prepare('DELETE FROM live_section_timers WHERE show_id = ?').run(showId);
database
.prepare(
`UPDATE piece_usages SET status = 'open'
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
)
.run(showId);
database
.prepare(
`UPDATE show_live_states
SET current_usage_id = NULL, started_at_utc = ?, ended_at_utc = NULL
WHERE show_id = ?`
)
.run(now, showId);
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId);
openShowTimerRun(database, showId, now);
const target = orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now);
},
nowValue,
true
);
}
function advanceCurrent( function advanceCurrent(
showId: string, showId: string,
commandId: string, commandId: string,
@@ -343,6 +838,7 @@ function advanceCurrent(
expectedRevision, expectedRevision,
status, status,
(database, state, now) => { (database, state, now) => {
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
if (!state.current_usage_id) throw new Error('NO_CURRENT'); if (!state.current_usage_id) throw new Error('NO_CURRENT');
const ordered = orderedUsages(database, showId); const ordered = orderedUsages(database, showId);
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id); const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
@@ -353,10 +849,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
); );
@@ -392,6 +890,7 @@ export function goBack(
expectedRevision, expectedRevision,
'back', 'back',
(database, state, now) => { (database, state, now) => {
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
const ordered = orderedUsages(database, showId); const ordered = orderedUsages(database, showId);
const currentIndex = state.current_usage_id const currentIndex = state.current_usage_id
? ordered.findIndex((usage) => usage.id === state.current_usage_id) ? ordered.findIndex((usage) => usage.id === state.current_usage_id)
@@ -427,6 +926,7 @@ export function selectUsage(
'select', 'select',
(database, state, now) => { (database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED'); if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
usageBelongsToShow(database, showId, usageId); usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return; if (state.current_usage_id === usageId) return;
if (state.current_usage_id) { if (state.current_usage_id) {
@@ -623,7 +1123,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
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.output_title_snapshot, u.presentation_snapshot, u.original_piece_id, `SELECT u.title_snapshot, u.output_title_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,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -637,7 +1137,6 @@ function getPresentationViewForShow(showId: string): PresentationView {
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string; output_title_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode']; presentation_mode_snapshot: PresentationContent['mode'];
presentation_source_label_snapshot: string; presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string; presentation_source_url_snapshot: string;
@@ -655,6 +1154,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
revision: state.presentation_revision, revision: state.presentation_revision,
content: { content: {
mode: usage.presentation_mode_snapshot, mode: usage.presentation_mode_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, 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
@@ -676,7 +1180,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources: [], sources: [],
images: [] images: (
database
.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
)
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text }))
} }
}; };
} }
@@ -693,9 +1204,33 @@ export async function getPresentationPreviewImage(token: string) {
export async function getPresentationMedia( export async function getPresentationMedia(
token: string, token: string,
mediaId: string snapshotId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> { ): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
void token; const database = getDatabase();
void mediaId; const showId = presentationShowId(database, token);
return null; if (!showId || !snapshotId) return null;
const media = database
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, ums.alt_text
FROM show_live_states state
JOIN usage_media_snapshots ums ON ums.usage_id = state.presentation_usage_id
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
)
.get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!media) return null;
try {
return {
data: await readFile(path.join(getServerPaths().uploads, media.storage_name)),
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text
};
} catch {
return null;
}
} }
+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;
+5 -2
View File
@@ -86,7 +86,7 @@ export function getPieces(): PreviewPiece[] {
(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
FROM pieces p FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status IN ('preparing', 'ready') WHERE p.status IN ('preparing', 'ready') AND p.deleted_at_utc IS NULL
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title` ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
) )
.all() as PieceRow[]; .all() as PieceRow[];
@@ -175,9 +175,12 @@ export function getTaxonomy() {
id: string; id: string;
name: string; name: string;
}[], }[],
topics: database.prepare('SELECT id, name FROM topics ORDER BY name').all() as { topics: database
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as {
id: string; id: string;
name: string; name: string;
iconName: string;
}[] }[]
}; };
} }
+175
View File
@@ -59,6 +59,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationDescription: edited.presentationDescription, presentationDescription: edited.presentationDescription,
presentationImageUrl: edited.presentationImageUrl, presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName, presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId,
ownNotes: edited.ownNotes, ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds durationSeconds: edited.durationSeconds
}; };
@@ -464,6 +465,180 @@ 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: [{ name: 'Gaming', iconName: '' }]
});
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([{ name: 'Gaming', iconName: '' }]);
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([{ name: 'Netzkultur', iconName: '' }]);
});
it('snapshots active media, their presentation choice and topic icons independently', () => {
const database = getDatabase();
database.prepare("UPDATE topics SET icon_name = 'gamepad' WHERE id = 'topic-gaming'").run();
const insertMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc, removed_at_utc)
VALUES (?, 'piece-console', ?, ?, 'image/png', 10, ?, ?, ?, ?)`
);
insertMedia.run(
'media-first',
'first.png',
'first-original.png',
'Erstes Bild',
0,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-second',
'second.png',
'second-original.png',
'Zweites Bild',
1,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-removed',
'removed.png',
'removed-original.png',
'Entferntes Bild',
2,
'2026-10-23T00:00:00.000Z',
'2026-10-23T01:00:00.000Z'
);
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-first')"
)
.run();
const show = createShow({ title: 'Medien', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Bilder',
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.topics).toEqual([{ name: 'Gaming', iconName: 'gamepad' }]);
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-first', altText: 'Erstes Bild', position: 0, isPresentation: true },
{
originalMediaId: 'media-second',
altText: 'Zweites Bild',
position: 1,
isPresentation: false
}
]);
expect(usage.presentationMediaId).toBe(usage.media[0].id);
const selected = updatePieceUsage(show.id, usage.id, added.revision, {
...editedUsage(usage),
presentationMediaId: usage.media[1].id
});
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media.map((item) => item.isPresentation)).toEqual([false, true]);
expect(() =>
updatePieceUsage(show.id, usage.id, selected.revision, {
...editedUsage(usage),
presentationMediaId: 'media-first'
})
).toThrow('INVALID_PRESENTATION_MEDIA');
expect(getShow(show.id)?.revision).toBe(selected.revision);
const duplicate = duplicatePieceUsage(show.id, usage.id, selected.revision);
const duplicateUsage = getShow(show.id)!.sections[0].usages[1];
expect(duplicateUsage.media.map((item) => item.originalMediaId)).toEqual([
'media-first',
'media-second'
]);
expect(duplicateUsage.presentationMediaId).toBe(duplicateUsage.media[1].id);
expect(duplicateUsage.media.map((item) => item.id)).not.toEqual(
usage.media.map((item) => item.id)
);
database
.prepare("UPDATE piece_media SET removed_at_utc = ? WHERE id = 'media-first'")
.run('2026-10-23T02:00:00.000Z');
database
.prepare("UPDATE piece_media SET alt_text = 'Neuer Alternativtext' WHERE id = 'media-second'")
.run();
database.prepare("DELETE FROM piece_presentation_media WHERE piece_id = 'piece-console'").run();
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-second')"
)
.run();
refreshPieceUsageFromOriginal(show.id, usage.id, duplicate.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-second', altText: 'Neuer Alternativtext', isPresentation: true }
]);
expect(duplicateUsage.media).toHaveLength(2);
});
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, {
+409 -14
View File
@@ -47,6 +47,7 @@ export type PieceUsage = {
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
interaction: string;
transition: string; transition: string;
presentation: string; presentation: string;
presentationMode: PresentationMode; presentationMode: PresentationMode;
@@ -60,6 +61,28 @@ 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: UsageTopic[];
media: PieceUsageMedia[];
presentationMediaId: string | null;
};
export type UsageTopic = { name: string; iconName: string };
export type PieceUsageMedia = {
id: string;
originalMediaId: string;
altText: string;
position: number;
isPresentation: boolean;
}; };
export type SentPieceUsage = { export type SentPieceUsage = {
@@ -135,6 +158,7 @@ export type PieceUsageUpdate = {
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
interaction?: string;
transition: string; transition: string;
presentation: string; presentation: string;
presentationMode: PresentationMode; presentationMode: PresentationMode;
@@ -147,6 +171,8 @@ export type PieceUsageUpdate = {
presentationSiteName: string; presentationSiteName: string;
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null;
}; };
export type MovePieceUsageInput = { export type MovePieceUsageInput = {
@@ -192,6 +218,7 @@ type UsageRow = {
intro_snapshot: string; intro_snapshot: string;
research_snapshot: string; research_snapshot: string;
moderation_snapshot: string; moderation_snapshot: string;
interaction_snapshot: string;
transition_snapshot: string; transition_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode; presentation_mode_snapshot: PresentationMode;
@@ -352,6 +379,43 @@ function usageSection(
return row; return row;
} }
function requireUsagesRemovable(
database: Database.Database,
showId: string,
usageIds: string[]
): void {
if (!usageIds.length) return;
const placeholders = usageIds.map(() => '?').join(', ');
const state = database
.prepare(
`SELECT current_usage_id, presentation_usage_id FROM show_live_states WHERE show_id = ?`
)
.get(showId) as
{ current_usage_id: string | null; presentation_usage_id: string | null } | undefined;
if (state?.current_usage_id && usageIds.includes(state.current_usage_id)) {
throw new Error('USAGE_IS_CURRENT');
}
if (state?.presentation_usage_id && usageIds.includes(state.presentation_usage_id)) {
throw new Error('USAGE_IS_PRESENTED');
}
if (
database
.prepare(`SELECT 1 FROM live_usage_runs WHERE usage_id IN (${placeholders}) LIMIT 1`)
.get(...usageIds)
) {
throw new Error('USAGE_HAS_LIVE_RUNS');
}
if (
database
.prepare(
`SELECT 1 FROM piece_usages WHERE id IN (${placeholders}) AND status != 'open' LIMIT 1`
)
.get(...usageIds)
) {
throw new Error('USAGE_NOT_OPEN');
}
}
function normalizePositions( function normalizePositions(
database: Database.Database, database: Database.Database,
table: 'show_sections' | 'piece_usages', table: 'show_sections' | 'piece_usages',
@@ -372,7 +436,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,
@@ -384,6 +448,7 @@ function usageFromRow(row: UsageRow): PieceUsage {
intro: row.intro_snapshot, intro: row.intro_snapshot,
research: row.research_snapshot, research: row.research_snapshot,
moderation: row.moderation_snapshot, moderation: row.moderation_snapshot,
interaction: row.interaction_snapshot,
transition: row.transition_snapshot, transition: row.transition_snapshot,
presentation: row.presentation_snapshot, presentation: row.presentation_snapshot,
presentationMode: row.presentation_mode_snapshot, presentationMode: row.presentation_mode_snapshot,
@@ -396,10 +461,258 @@ 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, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
)
.all(row.id) as { name: string; icon_name: string }[]
).map((topic) => ({ name: topic.name, iconName: topic.icon_name })),
media: (
database
.prepare(
`SELECT id, original_media_id, alt_text, position, is_presentation
FROM usage_media_snapshots WHERE usage_id = ? ORDER BY position`
)
.all(row.id) as {
id: string;
original_media_id: string;
alt_text: string;
position: number;
is_presentation: number;
}[]
).map((media) => ({
id: media.id,
originalMediaId: media.original_media_id,
altText: media.alt_text,
position: media.position,
isPresentation: media.is_presentation === 1
})),
presentationMediaId:
(
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(row.id) as { id: string } | undefined
)?.id ?? null
}; };
} }
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);
database.prepare('DELETE FROM usage_media_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, t.icon_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; icon_name: string }[];
const addTopic = database.prepare(
'INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position) VALUES (?, ?, ?, ?)'
);
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, topic.icon_name, position));
const media = database
.prepare(
`SELECT pm.id, pm.alt_text, pm.position,
CASE WHEN ppm.media_id IS NULL THEN 0 ELSE 1 END AS is_presentation
FROM piece_media pm
LEFT JOIN piece_presentation_media ppm
ON ppm.piece_id = pm.piece_id AND ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(pieceId) as {
id: string;
alt_text: string;
position: number;
is_presentation: number;
}[];
const addMedia = database.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES (?, ?, ?, ?, ?, ?)`
);
for (const item of media) {
addMedia.run(
randomUUID(),
usageId,
item.id,
item.alt_text,
item.position,
item.is_presentation
);
}
}
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, icon_name, position)
SELECT ?, name, icon_name, position FROM usage_topic_snapshots WHERE usage_id = ?`
)
.run(usageId, sourceUsageId);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
SELECT lower(hex(randomblob(16))), ?, original_media_id, alt_text, position, is_presentation
FROM usage_media_snapshots WHERE usage_id = ?`
)
.run(usageId, sourceUsageId);
}
export function listShows(): ShowListItem[] { export function listShows(): ShowListItem[] {
return ( return (
getDatabase() getDatabase()
@@ -484,7 +797,7 @@ export function getShow(id: string): Show | null {
.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.output_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.interaction_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,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -497,7 +810,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
@@ -759,6 +1072,12 @@ export function removeShowSection(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId); sectionBelongsToShow(database, showId, sectionId);
const usageIds = (
database.prepare('SELECT id FROM piece_usages WHERE show_section_id = ?').all(sectionId) as {
id: string;
}[]
).map((usage) => usage.id);
requireUsagesRemovable(database, showId, usageIds);
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId); database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
const ids = ( const ids = (
database database
@@ -811,7 +1130,7 @@ export function addPieceUsage(
const piece = database const piece = database
.prepare( .prepare(
`SELECT p.title, p.output_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.interaction_text, 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,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
@@ -828,6 +1147,7 @@ export function addPieceUsage(
intro: string; intro: string;
research_text: string; research_text: string;
moderation_notes: string; moderation_notes: string;
interaction_text: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode; presentation_mode: PresentationMode;
@@ -844,6 +1164,13 @@ export function addPieceUsage(
} }
| undefined; | undefined;
if (!piece) throw new Error('NOT_FOUND'); if (!piece) throw new Error('NOT_FOUND');
if (
!database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(input.pieceId)
) {
throw new Error('NOT_FOUND');
}
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY'); if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
const ids = ( const ids = (
database database
@@ -856,13 +1183,13 @@ export function addPieceUsage(
.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, output_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, interaction_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,
@@ -875,6 +1202,7 @@ export function addPieceUsage(
piece.intro, piece.intro,
piece.research_text, piece.research_text,
piece.moderation_notes, piece.moderation_notes,
piece.interaction_text,
piece.transition_text, piece.transition_text,
piece.presentation_text, piece.presentation_text,
piece.presentation_mode, piece.presentation_mode,
@@ -889,6 +1217,7 @@ 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 };
@@ -961,7 +1290,7 @@ export function duplicatePieceUsage(
const source = database const source = database
.prepare( .prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_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, interaction_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,
@@ -984,13 +1313,13 @@ export function duplicatePieceUsage(
.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, output_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, interaction_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,
@@ -1003,6 +1332,7 @@ export function duplicatePieceUsage(
source.intro_snapshot, source.intro_snapshot,
source.research_snapshot, source.research_snapshot,
source.moderation_snapshot, source.moderation_snapshot,
source.interaction_snapshot,
source.transition_snapshot, source.transition_snapshot,
source.presentation_snapshot, source.presentation_snapshot,
source.presentation_mode_snapshot, source.presentation_mode_snapshot,
@@ -1018,6 +1348,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 };
@@ -1036,7 +1367,7 @@ export function refreshPieceUsageFromOriginal(
const source = database const source = database
.prepare( .prepare(
`SELECT p.title, p.output_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.interaction_text, 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,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
@@ -1045,7 +1376,7 @@ export function refreshPieceUsageFromOriginal(
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position AND ps.position = p.presentation_source_position
WHERE u.id = ?` WHERE u.id = ? AND p.deleted_at_utc IS NULL`
) )
.get(usageId) as .get(usageId) as
| { | {
@@ -1055,6 +1386,7 @@ export function refreshPieceUsageFromOriginal(
intro: string; intro: string;
research_text: string; research_text: string;
moderation_notes: string; moderation_notes: string;
interaction_text: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode; presentation_mode: PresentationMode;
@@ -1073,7 +1405,7 @@ export function refreshPieceUsageFromOriginal(
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_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 = ?, interaction_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 = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
@@ -1088,6 +1420,7 @@ export function refreshPieceUsageFromOriginal(
source.intro, source.intro,
source.research_text, source.research_text,
source.moderation_notes, source.moderation_notes,
source.interaction_text,
source.transition_text, source.transition_text,
source.presentation_text, source.presentation_text,
source.presentation_mode, source.presentation_mode,
@@ -1102,6 +1435,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;
})(); })();
} }
@@ -1119,6 +1461,8 @@ export function updatePieceUsage(
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'), research: cleanText(input.research, 50_000, 'research'),
moderation: cleanText(input.moderation, 50_000, 'moderation'), moderation: cleanText(input.moderation, 50_000, 'moderation'),
interaction:
input.interaction === undefined ? null : cleanText(input.interaction, 50_000, 'interaction'),
transition: cleanText(input.transition, 50_000, 'transition'), transition: cleanText(input.transition, 50_000, 'transition'),
presentation: cleanText(input.presentation, 50_000, 'presentation'), presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes') ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
@@ -1140,7 +1484,7 @@ export function updatePieceUsage(
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_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 = ?, interaction_snapshot = COALESCE(?, interaction_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 = ?,
@@ -1154,6 +1498,7 @@ export function updatePieceUsage(
values.intro, values.intro,
values.research, values.research,
values.moderation, values.moderation,
values.interaction,
values.transition, values.transition,
values.presentation, values.presentation,
presentation.mode, presentation.mode,
@@ -1170,6 +1515,55 @@ 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);
}
}
if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId);
if (input.presentationMediaId !== null) {
database
.prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
)
.run(input.presentationMediaId, usageId);
}
}
return result; return result;
})(); })();
} }
@@ -1183,6 +1577,7 @@ export function removePieceUsage(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId); const source = usageSection(database, showId, usageId);
requireUsagesRemovable(database, showId, [usageId]);
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId); database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
const ids = ( const ids = (
database database
+78
View File
@@ -0,0 +1,78 @@
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',
iconName: 'ranking-star',
isActive: true,
formatIds: ['format-quiz']
});
expect(listTools()).toEqual([
{
id,
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true,
formatIds: ['format-quiz']
}
]);
updateTool(id, {
name: 'Tierlist',
startUrl: '',
description: '',
iconName: 'list-ol',
isActive: false,
formatIds: []
});
expect(listTools()).toEqual([]);
expect(listTools(true)[0]).toMatchObject({ id, iconName: 'list-ol', isActive: false });
expect(() =>
createTool({
name: 'Unsicher',
startUrl: 'javascript:alert(1)',
description: '',
iconName: 'wrench',
isActive: true,
formatIds: []
})
).toThrow('INVALID_TOOL_URL');
expect(() =>
createTool({
name: 'Unbekanntes Icon',
startUrl: '',
description: '',
iconName: 'kein-font-awesome-icon',
isActive: true,
formatIds: []
})
).toThrow('INVALID_ICON');
});
});
+156
View File
@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js';
export type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
iconName: 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),
iconName: (() => {
const iconName = clean(input.iconName, 120, true);
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
})(),
isActive: Boolean(input.isActive),
formatIds
};
}
export function listTools(includeInactive = false): Tool[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT id, name, start_url, description, icon_name, 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;
icon_name: 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,
iconName: row.icon_name,
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, icon_name, is_active, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.name,
input.startUrl,
input.description,
input.iconName,
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 = ?, icon_name = ?, is_active = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(
input.name,
input.startUrl,
input.description,
input.iconName,
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;
}
}
+2 -1
View File
@@ -2,5 +2,6 @@ import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => ({ export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user, user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/') presentationMode: url.pathname.startsWith('/heinzlive/'),
liveControllerMode: url.pathname === '/live'
}); });
+6 -1
View File
@@ -3,6 +3,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import NavIcon from '#lib/components/NavIcon.svelte'; import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children, data } = $props(); let { children, data } = $props();
@@ -33,6 +34,8 @@
{#if data.presentationMode} {#if data.presentationMode}
{@render children()} {@render children()}
{:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user} {:else if data.user}
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
@@ -84,7 +87,9 @@
<span>Sendewerk</span> <span>Sendewerk</span>
</a> </a>
<form method="POST" action="/logout"> <form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden">↗</button> <button class="mobile-logout" type="submit" aria-label="Abmelden"
><AppIcon name="right-from-bracket" /></button
>
</form> </form>
<ThemeToggle /> <ThemeToggle />
</header> </header>
+18 -2
View File
@@ -1,11 +1,17 @@
import { fail } 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 { listPieces, setPieceArchived } from '#lib/server/content.js'; import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
return { pieces: listPieces({ statuses: ['archived'] }) }; return {
pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'],
deleted: true
})
};
}; };
export const actions = { export const actions = {
@@ -18,5 +24,15 @@ export const actions = {
} catch { } catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' }); return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
} }
},
restoreDeleted: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
return { success: 'Gelöschtes Piece wurde wiederhergestellt.' };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
} }
} satisfies Actions; } satisfies Actions;
+23 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -28,8 +29,29 @@
</article>{/each} </article>{/each}
</div> </div>
{:else}<section class="quiet-panel planned-panel"> {:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div> <div class="planned-icon" aria-hidden="true"><AppIcon name="box-archive" /></div>
<h2>Keine Pieces archiviert</h2> <h2>Keine Pieces archiviert</h2>
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p> <p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section>{/if} </section>{/if}
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy">
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
</p>
{#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
{#if piece.summary}<p>{piece.summary}</p>{/if}
</div>
<form method="POST" action="?/restoreDeleted">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Keine gelöschten Pieces.</p>{/if}
</section>
</div> </div>
+11 -1
View File
@@ -1,7 +1,7 @@
import { fail } 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, setPieceDeleted } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -26,5 +26,15 @@ export const actions = {
: 'Ein Titel ist erforderlich.' : 'Ein Titel ist erforderlich.'
}); });
} }
},
delete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { deleted: true };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
}
} }
} satisfies Actions; } satisfies Actions;
+30 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -45,18 +46,37 @@
{#if data.pieces.length} {#if data.pieces.length}
<section class="inbox-list"> <section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2> <h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
{#each data.pieces as piece (piece.id)}<a {#each data.pieces as piece (piece.id)}<article class="inbox-item">
class="inbox-item" <a href={resolve('/fundus/[id]', { id: piece.id })}
href={resolve('/fundus/[id]', { id: piece.id })} ><span
><span ><strong>{piece.title}</strong><small
><strong>{piece.title}</strong><small >{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small ></span
></span ><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a
><span>Ausarbeiten →</span></a >
>{/each} <details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
>
<div class="piece-actions-popover">
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm(`„${piece.title}“ löschen?`) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form>
</div>
</details>
</article>{/each}
</section> </section>
{:else}<section class="quiet-panel planned-panel"> {:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div> <div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div>
<h2>Eingang ist leer</h2> <h2>Eingang ist leer</h2>
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p> <p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if} </section>{/if}
+26 -1
View File
@@ -8,7 +8,9 @@ import {
getTaxonomy, getTaxonomy,
listPieces, listPieces,
listPieceTemplates, listPieceTemplates,
setPieceArchived setPieceArchived,
setPieceDeleted,
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';
@@ -93,5 +95,28 @@ 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) });
}
},
delete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return {
intent: 'delete',
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
};
} catch (cause) {
return fail(409, { intent: 'delete', error: actionError(cause) });
}
} }
} satisfies Actions; } satisfies Actions;
+50 -20
View File
@@ -1,9 +1,11 @@
<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';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import { formatClock } from '#lib/format.js'; import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -13,7 +15,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)
) )
); );
@@ -64,14 +66,16 @@
<strong>{visiblePieces.length}</strong><span>Inhalte</span> <strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div> </div>
<details class="template-picker"> <details class="template-picker">
<summary><span aria-hidden="true">+</span> Neues Piece</summary> <summary><AppIcon name="plus" /> Neues Piece</summary>
<div class="template-popover"> <div class="template-popover">
<div class="template-grid"> <div class="template-grid">
{#each data.templates as template (template.id)} {#each data.templates as template (template.id)}
<form method="POST" action="?/create"> <form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} /> <input type="hidden" name="template" value={template.id} />
<button type="submit" <button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button ><AppIcon name={template.iconName} size="1.35rem" /><strong
>{template.name}</strong
><span>{template.description}</span></button
> >
</form> </form>
{/each} {/each}
@@ -109,7 +113,7 @@
> >
<div class="fundus-search-row"> <div class="fundus-search-row">
<label class="search-field" <label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input ><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
name="q" name="q"
type="search" type="search"
value={data.filters.query} value={data.filters.query}
@@ -118,7 +122,7 @@
> >
<button class="secondary-button" type="submit">Suchen</button> <button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker"> <details class="filter-picker">
<summary><span aria-hidden="true">≡</span> Weitere Filter</summary> <summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel"> <div class="filter-panel">
<label <label
><span>Status</span><select name="status" ><span>Status</span><select name="status"
@@ -193,19 +197,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}><AppIcon name="star" /></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">
<svg viewBox="0 0 24 24" fill="none"> <AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
<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} </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> {#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
</div> </div>
<div class="piece-classification"> <div class="piece-classification">
@@ -213,23 +227,24 @@
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span ><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
> >
<div class="piece-tags"> <div class="piece-tags">
<span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span {#if piece.formatId}<span>{piece.format}</span
>{topic.name}</span >{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
>{/each}
</div> </div>
</div> </div>
<div class="piece-state"> <div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label" <span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span >{statusLabel[piece.status]}</span
><span class="piece-facts" >{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span >{#if piece.durationSeconds}<span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span ><b>{formatClock(piece.durationSeconds)}</b> geplant</span
></span >{/if}{#if piece.sourceCount}<span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
>{/if}</span
>{/if}
> >
</div> </div>
<details class="piece-actions"> <details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`} <summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
><span aria-hidden="true">•••</span></summary
> >
<div class="piece-actions-popover"> <div class="piece-actions-popover">
<form method="POST" action="?/duplicate"> <form method="POST" action="?/duplicate">
@@ -245,6 +260,21 @@
value={piece.revision} value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button> /><button class="text-button danger-text" type="submit">Archivieren</button>
</form> </form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form>
</div> </div>
</details> </details>
</article> </article>
@@ -252,7 +282,7 @@
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Kein passendes Piece</h2> <h2>Kein passendes Piece</h2>
<p>Andere Filter wählen oder ein neues Piece anlegen.</p> <p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a> <a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
+49 -3
View File
@@ -11,23 +11,32 @@ import {
savePiece, savePiece,
savePieceAsTemplate, savePieceAsTemplate,
setPieceArchived, setPieceArchived,
setPieceDeleted,
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<
PieceInput, PieceInput,
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText' | 'intro'
| 'researchText'
| 'moderationNotes'
| 'interactionText'
| 'transitionText'
| 'presentationText'
> >
) { ) {
return { return {
intro: renderMarkdown(piece.intro), intro: renderMarkdown(piece.intro),
research: renderMarkdown(piece.researchText), research: renderMarkdown(piece.researchText),
moderation: renderMarkdown(piece.moderationNotes), moderation: renderMarkdown(piece.moderationNotes),
interaction: renderMarkdown(piece.interactionText ?? ''),
transition: renderMarkdown(piece.transitionText), transition: renderMarkdown(piece.transitionText),
presentation: renderMarkdown(piece.presentationText) presentation: renderMarkdown(piece.presentationText)
}; };
@@ -57,6 +66,9 @@ 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'), outputTitle: text(data, 'outputTitle'),
@@ -67,6 +79,7 @@ function pieceInput(data: FormData): PieceInput {
intro: text(data, 'intro'), intro: text(data, 'intro'),
researchText: text(data, 'researchText'), researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'), moderationNotes: text(data, 'moderationNotes'),
interactionText: text(data, 'interactionText'),
transitionText: text(data, 'transitionText'), transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'), presentationText: text(data, 'presentationText'),
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'], presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
@@ -83,9 +96,15 @@ function pieceInput(data: FormData): PieceInput {
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1', presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
presentationImageManual: text(data, 'presentationImageManual') === '1', presentationImageManual: text(data, 'presentationImageManual') === '1',
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null, presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
presentationMediaId: text(data, 'presentationMediaId') || 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] ?? ''
}))
}; };
} }
@@ -126,6 +145,7 @@ export const load: PageServerLoad = ({ locals, params }) => {
return { return {
piece, piece,
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
tools: listTools(true),
templates: listPieceTemplates().filter((template) => template.id !== 'blank'), templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
rendered: renderedFields(piece) rendered: renderedFields(piece)
}; };
@@ -195,7 +215,13 @@ export const actions = {
if (!(image instanceof File)) if (!(image instanceof File))
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' }); return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
try { try {
await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText')); await uploadPieceImage(
params.id,
Number(data.get('revision')),
image,
text(data, 'altText'),
data.get('selectForPresentation') === '1'
);
} catch (cause) { } catch (cause) {
const code = cause instanceof Error ? cause.message : ''; const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT') if (code === 'CONFLICT')
@@ -243,5 +269,25 @@ 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 });
}
},
delete: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(params.id, Number(data.get('revision')), true);
} catch (cause) {
return fail(409, { intent: 'delete', error: saveError(cause).error });
}
redirect(303, '/fundus');
} }
} satisfies Actions; } satisfies Actions;
+189 -32
View File
@@ -4,6 +4,9 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte'; import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte'; import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte'; import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PresentationContent } from '#lib/server/presentation.js'; import type { PresentationContent } from '#lib/server/presentation.js';
@@ -14,6 +17,7 @@
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2)))); let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
let revision = $state(untrack(() => data.piece.revision)); let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source })))); let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
let rendered = $state(untrack(() => ({ ...data.rendered }))); let rendered = $state(untrack(() => ({ ...data.rendered })));
let activeSection = $state<'script' | 'research' | 'presentation'>('script'); let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle'); let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
@@ -42,8 +46,16 @@
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id }) ? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: '' : ''
); );
let previewContent = $derived<PresentationContent>({ let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
let previewContent = $derived<
PresentationContent & { images: { id: string; altText: string }[] }
>({
mode: draft.presentationMode, mode: draft.presentationMode,
topics: data.taxonomy.topics
.filter((topic) => draft.topicIds.includes(topic.id))
.map((topic) => topic.name),
title: draft.outputTitle || draft.title, title: draft.outputTitle || draft.title,
html: rendered.presentation, html: rendered.presentation,
source: featuredSource source: featuredSource
@@ -64,8 +76,16 @@
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}` ? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId : draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}` ? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: '' : '',
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : []
}); });
let presentationPreviewImage = $derived(
selectedMedia
? draft.presentationMode === 'text'
? '/media'
: `/media/${selectedMedia.id}`
: sourcePreviewImage
);
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
@@ -147,6 +167,31 @@
scheduleSave(); scheduleSave();
} }
function removeTool(index: number) {
pieceTools.splice(index, 1);
scheduleSave();
}
function selectTools(ids: string[]) {
for (const id of ids) {
if (pieceTools.some((tool) => tool.toolId === id)) continue;
const tool = data.tools.find((item) => item.id === id);
if (tool)
pieceTools.push({
id: crypto.randomUUID(),
toolId: tool.id,
name: tool.name,
startUrl: tool.startUrl,
iconName: tool.iconName,
isActive: tool.isActive,
workUrl: '',
note: ''
});
}
pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId));
scheduleSave();
}
function presentationSourceChanged() { function presentationSourceChanged() {
draft.presentationMetadataFetchedAtUtc = null; draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = ''; draft.presentationProvider = '';
@@ -209,19 +254,24 @@
onrequestclose={requestClose} onrequestclose={requestClose}
> >
{#snippet header()} {#snippet header()}
<p class="eyebrow">Fundusfassung</p> <div class="piece-editor-kicker">
<label class="piece-dialog-title"> <p class="eyebrow">Fundusfassung</p>
<span class="sr-only">Titel</span> <form method="POST" action="?/favorite" use:enhance>
<textarea <input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
form="piece-editor-form" <button
name="title" class="piece-favorite"
required class:active={data.piece.isFavorite}
rows="1" type="submit"
maxlength="200" aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
aria-label="Titel" aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
bind:value={draft.title} >
oninput={scheduleSave}></textarea> </form>
</label> </div>
<InlineTitleEditor
form="piece-editor-form"
bind:value={draft.title}
oncommit={scheduleSave}
/>
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1> <h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
{/snippet} {/snippet}
@@ -258,6 +308,13 @@
rows={10} rows={10}
bind:value={draft.moderationNotes} bind:value={draft.moderationNotes}
/> />
<MarkdownEditorField
name="interactionText"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interactionText}
/>
<MarkdownEditorField <MarkdownEditorField
name="transitionText" name="transitionText"
label="Überleitung" label="Überleitung"
@@ -307,17 +364,16 @@
bind:value={draft.summary}></textarea></label bind:value={draft.summary}></textarea></label
> >
<fieldset> <fieldset>
<legend>Themen</legend> <legend>Themen</legend><ChoicePicker
<div class="choice-grid"> items={data.taxonomy.topics}
{#each data.taxonomy.topics as item (item.id)}<label selectedIds={draft.topicIds}
><input label="Themen"
type="checkbox" fieldName="topicIds"
name="topicIds" onselectionchange={(ids) => {
value={item.id} draft.topicIds = ids;
bind:group={draft.topicIds} scheduleSave();
/>{item.name}</label }}
>{/each} />
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend> <legend>Vorgeschlagene Abschnittskategorien</legend>
@@ -396,6 +452,63 @@
</div>{/each} </div>{/each}
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if} </div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
</section> </section>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Arbeitsmittel</p>
<h3>Werkzeuge</h3>
</div>
<ChoicePicker
items={data.tools
.filter((tool) => tool.isActive)
.map((tool) => ({
id: tool.id,
name: tool.name,
description: tool.description,
iconName: tool.iconName,
meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : ''
}))}
selectedIds={pieceTools.map((tool) => tool.toolId)}
label="Werkzeuge"
onselectionchange={selectTools}
/>
</div>
{#if pieceTools.length}<div class="source-list">
{#each pieceTools as tool, index (tool.id)}<div
class="source-row tool-assignment-row"
>
<input type="hidden" name="toolId" value={tool.toolId} />
<div class="tool-assignment-name">
<AppIcon name={tool.iconName || 'wrench'} /><span
><strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small
>{/if}</span
>
</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"
placeholder="Optional"
bind:value={tool.note}
/></label
>
<button
class="text-button danger-text"
type="button"
onclick={() => removeTool(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="context-empty">Noch kein Werkzeug zugeordnet.</p>{/if}
</section>
</section> </section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}> <section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
@@ -496,7 +609,7 @@
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay <PresentationDisplay
content={previewContent} content={previewContent}
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage} imageSrc={sourceImageUnavailable ? '' : presentationPreviewImage}
/> />
</aside> </aside>
</div> </div>
@@ -526,17 +639,44 @@
</section> </section>
</form> </form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}> <section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-subsection-heading"> <div class="piece-subsection-heading">
<div> <div>
<p class="eyebrow">Private Dateien</p> <p class="eyebrow">Hochgeladene Bilder</p>
<h3>Material</h3> <h3>Präsentationsbild</h3>
</div> </div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small> <small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div> </div>
{#if data.piece.media.length}<div class="media-grid"> {#if data.piece.media.length}<label class="media-none"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value=""
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
scheduleSave();
}}
/> Kein hochgeladenes Bild</label
>
<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure> {#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} /> <label class="media-presentation-choice"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value={media.id}
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
scheduleSave();
}}
/><img src={`/media/${media.id}`} alt={media.altText} /><span
>Als Präsentationsbild</span
></label
>
<figcaption> <figcaption>
<span>{media.altText || media.originalName}</span> <span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage"> <form method="POST" action="?/deleteImage">
@@ -568,6 +708,10 @@
><button class="secondary-button" type="submit" disabled={actionsBlocked} ><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button >Bild hochladen</button
> >
<label class="upload-select"
><input type="checkbox" name="selectForPresentation" value="1" checked /> Direkt als Präsentationsbild
verwenden</label
>
</form> </form>
</section> </section>
@@ -602,6 +746,19 @@
disabled={actionsBlocked}>Archivieren</button disabled={actionsBlocked}>Archivieren</button
> >
</form> </form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece löschen</button
>
</form>
</div> </div>
<div class="piece-dialog-primary-action"> <div class="piece-dialog-primary-action">
<span <span
@@ -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,6 +18,9 @@ export const load: PageServerLoad = ({ locals, params }) => {
images: { id: string; altText: string }[]; images: { id: string; altText: string }[];
} = { } = {
mode: piece.presentationMode, mode: piece.presentationMode,
topics: getTaxonomy()
.topics.filter((topic) => piece.topicIds.includes(topic.id))
.map((topic) => topic.name),
title: piece.outputTitle || piece.title, title: piece.outputTitle || piece.title,
html: renderMarkdown(piece.presentationText), html: renderMarkdown(piece.presentationText),
source, source,
@@ -37,8 +40,10 @@ export const load: PageServerLoad = ({ locals, params }) => {
piece.presentationProviderId, piece.presentationProviderId,
source?.timecodeSeconds ?? null source?.timecodeSeconds ?? null
), ),
sources: piece.sources, sources: [],
images: piece.media.map((item) => ({ id: item.id, altText: item.altText })) images: piece.media
.filter((item) => item.id === piece.presentationMediaId)
.map((item) => ({ id: item.id, altText: item.altText }))
}; };
return { pieceId: piece.id, content }; return { pieceId: piece.id, content };
}; };
@@ -14,8 +14,10 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div> <div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay <PresentationDisplay
content={data.content} content={data.content}
imageSrc={data.content.mode === 'text' imageSrc={data.content.images.length
? '/media' ? data.content.mode === 'text'
? '/media'
: `/media/${data.content.images[0].id}`
: `/fundus/${data.pieceId}/praesentation/preview-image`} : `/fundus/${data.pieceId}/praesentation/preview-image`}
/> />
</main> </main>
+5 -9
View File
@@ -56,7 +56,7 @@
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head> </svelte:head>
<main class="heinzlive-output"> <main class:empty={!view.content} class="heinzlive-output">
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status"> <div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'} <span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div> </div>
@@ -64,16 +64,12 @@
{#key view.revision} {#key view.revision}
<PresentationDisplay <PresentationDisplay
content={view.content} content={view.content}
imageSrc={view.content.mode === 'text' imageSrc={view.content.images.length
? `/heinzlive/${page.params.token}/media` ? view.content.mode === 'text'
? `/heinzlive/${page.params.token}/media`
: `/heinzlive/${page.params.token}/media/${view.content.images[0].id}`
: `/heinzlive/${page.params.token}/preview-image`} : `/heinzlive/${page.params.token}/preview-image`}
/> />
{/key} {/key}
{:else}
<section class="output-idle">
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
<h1>Bereit für den nächsten Inhalt</h1>
<p>Die Moderation wählt ausdrücklich aus, was hier erscheint.</p>
</section>
{/if} {/if}
</main> </main>
+81
View File
@@ -5,14 +5,22 @@ import { requireUser } from '#lib/server/auth.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel, clearPresentationChannel,
endLive,
getLiveView, getLiveView,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
pauseLive,
presentUsage, presentUsage,
rotatePresentationChannelKey, rotatePresentationChannelKey,
resetSectionTimer,
resetTotalTimer,
resumeEndedLive,
resumeLive,
selectUsage, selectUsage,
setShowStatus,
skipCurrent, skipCurrent,
startNewLiveRun,
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js'; import { listShows } from '#lib/server/shows.js';
@@ -41,6 +49,13 @@ function actionError(cause: unknown) {
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' }; return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND') if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' }; return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
if (code === 'NO_SECTION_TIMER')
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' }; if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' }; return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
} }
@@ -75,6 +90,72 @@ export const actions = {
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
); );
}, },
pause: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('pause', () =>
pauseLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resume: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resume', () =>
resumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resetSectionTimer: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resetSectionTimer', () =>
resetSectionTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resetTotalTimer: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resetTotalTimer', () =>
resetTotalTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
setStatus: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const status = text(data, 'status');
if (status !== 'draft' && status !== 'ready') {
return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' });
}
return attempt('setStatus', () =>
setShowStatus(
text(data, 'showId'),
text(data, 'commandId'),
Number(data.get('revision')),
Number(data.get('showRevision')),
status
)
);
},
end: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('end', () =>
endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resumeEnded: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resumeEnded', () =>
resumeEndedLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
startNewRun: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('startNewRun', () =>
startNewLiveRun(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
complete: async ({ locals, request }) => { complete: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
File diff suppressed because it is too large. Load diff
+30 -4
View File
@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js'; import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
function berlinLocalToUtc(value: string): string | null { function berlinLocalToUtc(value: string): string | null {
@@ -34,12 +35,37 @@ function berlinLocalToUtc(value: string): string | null {
return new Date(candidate).toISOString(); return new Date(candidate).toISOString();
} }
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); const user = requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() }; const today = new Date();
const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
previousMonth: shiftMonth(selectedMonth, -1),
nextMonth: shiftMonth(selectedMonth, 1),
todayMonth,
todayDate: berlinDateKey(today)
};
}; };
export const actions = { export const actions = {
setView: async ({ locals, request }) => {
const user = requireUser(locals.user);
const data = await request.formData();
const view = String(data.get('view') || '');
const todayMonth = berlinMonthKey(new Date());
const month = normalizeMonthKey(String(data.get('month') || ''), todayMonth);
try {
setShowOverviewView(user.id, view);
} catch {
return fail(400, { error: 'Die Ansicht konnte nicht gespeichert werden.' });
}
redirect(303, `/sendungen?month=${encodeURIComponent(month)}`);
},
create: async ({ locals, request }) => { create: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+199 -48
View File
@@ -1,9 +1,35 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js'; import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let scheduledShows = $derived(data.shows.filter((show) => show.startsAtUtc !== null));
let undatedShows = $derived(data.shows.filter((show) => show.startsAtUtc === null));
let calendarDays = $derived(buildCalendarDays(data.selectedMonth, data.todayDate));
let showsByDate = $derived.by(() => {
const grouped: Record<string, (typeof data.shows)[number][]> = {};
for (const show of scheduledShows) {
const key = berlinDateKey(show.startsAtUtc!);
const shows = grouped[key] ?? [];
shows.push(show);
grouped[key] = shows;
}
for (const shows of Object.values(grouped)) {
shows.sort((left, right) => left.startsAtUtc!.localeCompare(right.startsAtUtc!));
}
return grouped;
});
let agendaDays = $derived(
calendarDays.filter((day) => day.inSelectedMonth && (showsByDate[day.date]?.length ?? 0) > 0)
);
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
hour: '2-digit',
minute: '2-digit'
});
function berlinDate(value: string | null) { function berlinDate(value: string | null) {
if (!value) return 'Termin offen'; if (!value) return 'Termin offen';
@@ -13,22 +39,52 @@
timeStyle: 'short' timeStyle: 'short'
}).format(new Date(value)); }).format(new Date(value));
} }
function berlinTime(value: string) {
return timeFormatter.format(new Date(value));
}
function agendaDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'UTC',
weekday: 'long',
day: '2-digit',
month: 'long'
}).format(new Date(`${value}T12:00:00Z`));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
</script> </script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page"> <div class="page-wrap shows-page">
<header class="page-heading"> <header class="page-heading shows-heading">
<div> <div>
<h1>Sendungen</h1> <h1>Sendungen</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p> <p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
</div> </div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} />
<button type="submit" name="view" value="list" aria-pressed={data.view === 'list'}>
<AppIcon name="list" /> Liste
</button>
<button type="submit" name="view" value="calendar" aria-pressed={data.view === 'calendar'}>
<AppIcon name="calendar" /> Kalender
</button>
</form>
</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}
<section class="show-create-panel"> <details class="show-create-panel">
<div> <summary><AppIcon name="plus" /> Neue Sendung planen</summary>
<div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p> <p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2> <h2>Leer starten oder Ablauf übernehmen</h2>
</div> </div>
@@ -45,60 +101,155 @@
<label <label
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label >Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label
> >
<label <fieldset class="show-template-grid">
>Ausgangspunkt<select name="templateId" value={form?.templateId ?? ''} <legend>Ausgangspunkt</legend>
><option value="">Leer starten</option <label
>{#each data.templates as template (template.id)}<option value={template.id} ><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
>{template.name}</option name="newspaper"
>{/each}</select /><span
></label ><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span
> ></label
>
{#each data.templates as template (template.id)}<label
><input
type="radio"
name="templateId"
value={template.id}
checked={form?.templateId === template.id}
/><AppIcon name="list-check" /><span
><strong>{template.name}</strong><small>{template.description}</small></span
></label
>{/each}
</fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button> <button class="primary-button" type="submit">Sendung anlegen</button>
</form> </form>
<div class="template-notes"> </details>
{#each data.templates as template (template.id)}<p>
<strong>{template.name}:</strong>
{template.description}
</p>{/each}
</div>
</section>
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <header>
<h2 id="show-library-title">Geplante Sendungen</h2> <h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
<span>{data.shows.length}</span> <span>{data.shows.length}</span>
</header> </header>
{#if data.shows.length}
<div class="show-card-list"> {#if data.view === 'list'}
{#each data.shows as show (show.id)} {#if scheduledShows.length}<div class="show-card-list">
<a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}> {#each scheduledShows as show (show.id)}<a
<div> class="show-card"
<span class={`show-state ${show.status}`} href={resolve('/sendungen/[id]', { id: show.id })}
>{show.status === 'ready' >
? 'Bereit' <div>
: show.status === 'draft' <span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
? 'Entwurf' <h3>{show.title}</h3>
: show.status === 'live' <p>{berlinDate(show.startsAtUtc)} Uhr</p>
? 'Live' </div>
: 'Abgeschlossen'}</span <div class="show-card-facts">
> {#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
<h3>{show.title}</h3> >{/if}{#if show.totalDurationSeconds}<span
<p>{berlinDate(show.startsAtUtc)} Uhr</p> ><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
</div> >{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
<div class="show-card-facts"> </div>
<span><strong>{show.sectionCount}</strong> Abschnitte</span><span </a>{/each}
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span </div>{:else}<div class="empty-state compact">
><b aria-hidden="true">→</b> <h2>Keine terminierten Sendungen</h2>
</div> <p>Sendungen ohne Termin bleiben unten erreichbar.</p>
</a> </div>{/if}
{/each}
</div>
{:else} {:else}
<div class="empty-state"> <div class="show-month-toolbar">
<span aria-hidden="true">+</span> <div>
<h2>Noch keine Sendung</h2> <p class="eyebrow">Monatsansicht</p>
<p>Oben kann der erste Stream geplant werden.</p> <h3>{formatMonthLabel(data.selectedMonth)}</h3>
</div>
<!-- Query-only navigation stays on the resolved Sendungen route. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<nav aria-label="Kalendermonat" class="show-month-navigation">
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
>
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a
>
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
aria-label="Nächster Monat"><AppIcon name="chevron-right" /></a
>
</nav>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
<div class="show-calendar" aria-label={formatMonthLabel(data.selectedMonth)}>
<div class="show-calendar-weekdays" aria-hidden="true">
{#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as weekday (weekday)}<span
>{weekday}</span
>{/each}
</div>
<div class="show-calendar-grid">
{#each calendarDays as day (day.date)}<section
class:outside={!day.inSelectedMonth}
class:today={day.isToday}
class="show-calendar-day"
aria-label={day.date}
>
<strong>{day.dayNumber}</strong>
{#if day.inSelectedMonth}<div class="show-calendar-events">
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
href={resolve('/sendungen/[id]', { id: show.id })}
class={`show-calendar-event ${show.status}`}
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span>
<small>{statusLabel(show.status)}</small>
</a>{/each}
</div>{/if}
</section>{/each}
</div>
</div>
<div class="show-agenda" aria-label={`Termine ${formatMonthLabel(data.selectedMonth)}`}>
{#if agendaDays.length}{#each agendaDays as day (day.date)}<section
class:today={day.isToday}
class="show-agenda-day"
>
<h3>{agendaDate(day.date)}</h3>
<div>
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
class="show-agenda-event"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span
>
</a>{/each}
</div>
</section>{/each}{:else}<div class="empty-state compact">
<h2>Keine Termine in diesem Monat</h2>
</div>{/if}
</div> </div>
{/if} {/if}
</section> </section>
{#if undatedShows.length}<section
class="show-library show-undated"
aria-labelledby="undated-title"
>
<header>
<h2 id="undated-title">Ohne Termin</h2>
<span>{undatedShows.length}</span>
</header>
<div class="show-card-list">
{#each undatedShows as show (show.id)}<a
class="show-card"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<h3>{show.title}</h3>
<p>Termin offen</p>
</div>
<div class="show-card-facts">
<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>{/each}
</div>
</section>{/if}
</div> </div>
+21 -1
View File
@@ -92,6 +92,16 @@ function actionError(cause: unknown) {
if (code === 'ORIGINAL_NOT_FOUND') if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' }; return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
code
)
)
return {
status: 409,
error:
'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
};
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code.startsWith('INVALID_')) if (code.startsWith('INVALID_'))
@@ -215,6 +225,9 @@ export const actions = {
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'),
@@ -224,6 +237,7 @@ export const actions = {
intro: text(data, 'intro'), intro: text(data, 'intro'),
research: text(data, 'research'), research: text(data, 'research'),
moderation: text(data, 'moderation'), moderation: text(data, 'moderation'),
interaction: text(data, 'interaction'),
transition: text(data, 'transition'), transition: text(data, 'transition'),
presentation: text(data, 'presentation'), presentation: text(data, 'presentation'),
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'], presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
@@ -235,7 +249,13 @@ 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'),
presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({
id,
workUrl: toolWorkUrls[index] ?? '',
note: toolNotes[index] ?? ''
}))
}) })
); );
}, },
+16 -9
View File
@@ -6,6 +6,7 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -35,7 +36,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)
) )
); );
@@ -207,7 +208,7 @@
<div class="page-wrap builder-page"> <div class="page-wrap builder-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}>← Sendungen</a> <a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a>
<h1>{data.show.title}</h1> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -219,7 +220,7 @@
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span> <strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div> </div>
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)} <a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
>Live-Steuerung <span>↗</span></a >Live-Steuerung <AppIcon name="display" /></a
> >
</div> </div>
</header> </header>
@@ -411,7 +412,11 @@
><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"
><AppIcon name="star" /></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>
@@ -446,7 +451,7 @@
{/each} {/each}
</div> </div>
{#if !visiblePieces.length}<div class="empty-state"> {#if !visiblePieces.length}<div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2> <h2>Keine verfügbaren Pieces</h2>
<p> <p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
@@ -522,7 +527,7 @@
/><input type="hidden" name="position" value={index - 1} /><button /><input type="hidden" name="position" value={index - 1} /><button
type="submit" type="submit"
disabled={index === 0} disabled={index === 0}
aria-label={`${section.name} nach oben`}>↑</button aria-label={`${section.name} nach oben`}><AppIcon name="arrow-up" /></button
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection"> <form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
@@ -533,7 +538,7 @@
/><input type="hidden" name="position" value={index + 1} /><button /><input type="hidden" name="position" value={index + 1} /><button
type="submit" type="submit"
disabled={index === data.show.sections.length - 1} disabled={index === data.show.sections.length - 1}
aria-label={`${section.name} nach unten`}>↓</button aria-label={`${section.name} nach unten`}><AppIcon name="arrow-down" /></button
> >
</form> </form>
</div> </div>
@@ -599,7 +604,8 @@
/><button /><button
type="submit" type="submit"
disabled={usageIndex === 0} disabled={usageIndex === 0}
aria-label={`${usage.title} nach oben`}>↑</button aria-label={`${usage.title} nach oben`}
><AppIcon name="arrow-up" /></button
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
@@ -614,7 +620,8 @@
/><button /><button
type="submit" type="submit"
disabled={usageIndex === section.usages.length - 1} disabled={usageIndex === section.usages.length - 1}
aria-label={`${usage.title} nach unten`}>↓</button aria-label={`${usage.title} nach unten`}
><AppIcon name="arrow-down" /></button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
+66 -1
View File
@@ -4,9 +4,11 @@ import {
createTaxonomy, createTaxonomy,
deleteTaxonomy, deleteTaxonomy,
getTaxonomy, getTaxonomy,
updateTaxonomy,
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 +27,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 +43,27 @@ 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 === 'INVALID_ICON') return 'Bitte ein Icon aus der angebotenen Auswahl verwenden.';
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') || ''),
iconName: String(data.get('iconName') || ''),
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);
@@ -112,12 +133,36 @@ export const actions = {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' }); return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
} }
try { try {
createTaxonomy(kind, String(data.get('name') || ''), String(data.get('color') || '')); createTaxonomy(
kind,
String(data.get('name') || ''),
String(data.get('color') || ''),
String(data.get('iconName') || '')
);
return { intent: 'taxonomy', success: 'Eintrag angelegt.' }; return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) }); return fail(400, { intent: 'taxonomy', error: actionError(cause) });
} }
}, },
updateTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
updateTaxonomy(
kind,
String(data.get('taxonomyId') || ''),
String(data.get('name') || ''),
String(data.get('iconName') || '')
);
return { intent: 'taxonomy', success: 'Eintrag aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
},
deleteTaxonomy: async ({ locals, request }) => { deleteTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user); requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -131,5 +176,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;
+74 -15
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
function confirmTaxonomyRemoval(event: SubmitEvent) { function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault(); if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
@@ -12,7 +16,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 +117,46 @@
</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>
<button class="compact-add-button" type="button" onclick={() => toolDialog.showModal()}>
<AppIcon name="plus" /> Werkzeug hinzufügen
</button>
</div>
<p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
</p>
<div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)}
<button
type="button"
class:inactive={!tool.isActive}
aria-label={`${tool.name} bearbeiten`}
onclick={() => toolDialog.showModal(tool)}
>
<span class="catalog-entry-icon"><AppIcon name={tool.iconName || 'wrench'} /></span>
<span class="catalog-entry-copy">
<strong>{tool.name}</strong>
<small
>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}{#if tool.formatIds.length}
· {data.taxonomy.formats
.filter((format) => tool.formatIds.includes(format.id))
.map((format) => format.name)
.join(', ')}{/if}</small
>
</span>
<AppIcon name="pen" />
</button>
{/each}
</div>
</section>
<ToolEditorDialog bind:this={toolDialog} formats={data.taxonomy.formats} />
<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">
@@ -126,7 +170,9 @@
type="hidden" type="hidden"
name="taxonomyId" name="taxonomyId"
value={category.id} value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}>×</button> /><button type="submit" aria-label={`${category.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span </form></span
>{/each} >{/each}
</div> </div>
@@ -158,7 +204,9 @@
type="hidden" type="hidden"
name="taxonomyId" name="taxonomyId"
value={format.id} value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}>×</button> /><button type="submit" aria-label={`${format.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span </form></span
>{/each} >{/each}
</div> </div>
@@ -173,20 +221,31 @@
><button class="text-button" type="submit">Hinzufügen</button> ><button class="text-button" type="submit">Hinzufügen</button>
</form> </form>
</section> </section>
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel topic-management-panel">
<p class="eyebrow">Themen</p> <p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2> <h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list editable-taxonomy"> <div class="topic-settings-list">
{#each data.taxonomy.topics as topic (topic.id)}<span {#each data.taxonomy.topics as topic (topic.id)}
>{topic.name} <details>
<summary
><AppIcon name={topic.iconName || 'circle'} /><strong>{topic.name}</strong><AppIcon
name="pen"
/></summary
>
<form class="topic-edit-form" method="POST" action="?/updateTaxonomy">
<input type="hidden" name="kind" value="topic" />
<input type="hidden" name="taxonomyId" value={topic.id} />
<label>Name<input name="name" required maxlength="80" value={topic.name} /></label>
<IconPicker name={topic.iconName} />
<button class="secondary-button" type="submit">Änderungen speichern</button>
</form>
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}> <form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" /><input <input type="hidden" name="kind" value="topic" />
type="hidden" <input type="hidden" name="taxonomyId" value={topic.id} />
name="taxonomyId" <button class="text-button danger-text" type="submit">Thema löschen</button>
value={topic.id} </form>
/><button type="submit" aria-label={`${topic.name} entfernen`}>×</button> </details>
</form></span {/each}
>{/each}
</div> </div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy"> <form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label <input type="hidden" name="kind" value="topic" /><label
@@ -196,7 +255,7 @@
maxlength="80" maxlength="80"
placeholder="Neues Thema" placeholder="Neues Thema"
/></label /></label
><button class="text-button" type="submit">Hinzufügen</button> ><IconPicker name="" /><button class="text-button" type="submit">Hinzufügen</button>
</form> </form>
</section> </section>
</div> </div>
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 3.7 MiB

+554 -39
View File
@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -40,6 +40,12 @@ async function openEditorialField(page: Page, heading: string) {
} }
} }
async function editInlineTitle(scope: Page | Locator, title: string, value: string) {
await scope.getByRole('button', { name: `${title} Titel bearbeiten` }).click();
await scope.getByLabel('Titel', { exact: true }).fill(value);
await scope.getByLabel('Titel', { exact: true }).press('Enter');
}
async function login(page: Page, email = adminEmail, password = adminPassword) { async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('E-Mail').fill(email); await page.getByLabel('E-Mail').fill(email);
@@ -190,6 +196,48 @@ function removePresentationFixture() {
database.close(); database.close();
} }
function addCalendarFixture() {
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'));
const insert = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
);
const now = '2026-10-24T10:00:00.000Z';
database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now);
insert.run(
'e2e-calendar-next',
'Kalender Novembertermin',
'2026-11-03T17:00:00.000Z',
now,
now
);
insert.run('e2e-calendar-undated', 'Kalender ohne Termin', null, now, now);
})();
database.close();
}
function removeCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-calendar-%'").run();
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('/');
@@ -311,6 +359,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
.evaluate((element) => ((element as HTMLDetailsElement).open = true)); .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 await page
.locator('.filter-picker') .locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true)); .evaluate((element) => ((element as HTMLDetailsElement).open = true));
@@ -392,8 +451,9 @@ test('sent and planned source filters stay independent and removing the last usa
}); });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung'); await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByRole('radio', { name: /Standardablauf/ }).check();
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');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
@@ -467,6 +527,56 @@ test('sent and planned source filters stay independent and removing the last usa
} }
}); });
test('show overview switches persistently between list, calendar and mobile agenda', async ({
page
}, testInfo) => {
addCalendarFixture();
try {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen?month=2026-10');
await page.getByRole('button', { name: 'Kalender' }).click();
await expect(page.getByRole('heading', { name: 'Oktober 2026' })).toBeVisible();
const octoberDay = page
.locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
await expect(page.locator('.show-undated')).toContainText('Kalender ohne Termin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-desktop.png'),
fullPage: true
});
await page.getByRole('link', { name: 'Nächster Monat' }).click();
await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Kalender Novembertermin' })).toBeVisible();
await page.reload();
await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute(
'aria-pressed',
'true'
);
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('link', { name: 'Vorheriger Monat' }).click();
await expect(page.locator('.show-calendar')).toBeHidden();
await expect(page.locator('.show-agenda')).toContainText('Kalender Frühtermin');
await expect(page.locator('.show-agenda')).toContainText('Kalender Spättermin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-mobile.png'),
fullPage: true
});
await page.getByRole('button', { name: 'Liste' }).click();
await expect(page.getByRole('heading', { name: 'Geplante Sendungen' })).toBeVisible();
} finally {
removeCalendarFixture();
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
@@ -543,8 +653,9 @@ test('a show can be planned from a template with independent usages and reserve'
test.slow(); 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.getByText('Neue Sendung planen', { exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC'); await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByRole('radio', { name: /Standardablauf/ }).check();
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-]+$/);
const showUrl = page.url(); const showUrl = page.url();
@@ -626,7 +737,11 @@ 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', { exact: true }).fill('Konsole als Auftakt'); await editInlineTitle(
usages.nth(0),
'Die kleine Konsole mit dem großen Dreh',
'Konsole als Auftakt'
);
await usages.nth(0).locator('.piece-meta-details > summary').click(); 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).getByRole('button', { name: 'Recherche & Quellen' }).click(); await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click();
@@ -647,7 +762,11 @@ 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', { exact: true }).fill('Konsole als Rückblick'); await editInlineTitle(
usages.nth(1),
'Die kleine Konsole mit dem großen Dreh',
'Konsole als Rückblick'
);
await usages.nth(1).locator('.piece-meta-details > summary').click(); 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).getByRole('button', { name: 'Recherche & Quellen' }).click(); await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click();
@@ -854,6 +973,98 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
]); ]);
expect(boxes[0]?.y).toBe(boxes[1]?.y); expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y);
const topicIconPicker = page.locator('.topic-management-panel .taxonomy-form .icon-picker');
for (const width of [1280, 960, 390]) {
await page.setViewportSize({ width, height: 900 });
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible();
const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
}
await page.setViewportSize({ width: 1280, height: 900 });
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
await topicIconPicker.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await topicIconPicker.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish');
});
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung');
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('.tool-editor-dialog')).toBeHidden();
await expectNoHorizontalOverflow(page);
}
await page.getByRole('button', { name: 'Werkzeug hinzufügen' }).click();
const create = page.getByRole('dialog', { name: 'Neues Werkzeug' });
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.getByText('Icon auswählen', { exact: true }).click();
await create.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-desktop.png') });
await create.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await create.getByText('Formate auswählen', { exact: true }).click();
await create.getByRole('checkbox', { name: 'Quiz' }).check();
await create.getByRole('button', { name: 'Auswahl schließen' }).click();
await page.screenshot({ path: testInfo.outputPath('tool-create-desktop.png'), fullPage: true });
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
const tool = page.locator('.tool-settings-list > button').filter({ hasText: 'E2E Tierlist' });
await expect(tool).toContainText('Aktiv');
await expect(tool.locator('.catalog-entry-icon svg')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true });
await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.getByText('Werkzeuge auswählen', { exact: true }).click();
const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ });
await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible();
await toolChoice.check();
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 });
const toolsSection = page.locator('.piece-source-editor').filter({ hasText: 'Werkzeuge' });
await toolsSection.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath('piece-tools-desktop.png') });
await page.goto('/verwaltung');
const storedTool = page
.locator('.tool-settings-list > button')
.filter({ hasText: 'E2E Tierlist' });
await storedTool.click();
const edit = page.getByRole('dialog', { name: 'E2E Tierlist' });
await edit.getByRole('checkbox', { name: /^Aktiv/ }).uncheck();
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(edit.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await edit.getByText('Icon auswählen', { exact: true }).click();
await edit.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-mobile.png') });
await edit.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('tool-edit-mobile.png') });
await edit.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(storedTool).toContainText('Deaktiviert');
await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true });
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 page
.locator('.piece-source-editor')
.filter({ hasText: 'Werkzeuge' })
.scrollIntoViewIfNeeded();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png') });
}); });
test('validated YouTube and TikTok players switch without exposing private fields', async ({ test('validated YouTube and TikTok players switch without exposing private fields', async ({
@@ -874,10 +1085,12 @@ 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');
await page.getByText('Menü', { exact: true }).click();
const popupPromise = page.waitForEvent('popup'); const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
const output = await popupPromise; const output = await popupPromise;
await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible(); await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click(); await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
const youtube = output.locator('iframe[title="YouTube Vorschau"]'); const youtube = output.locator('iframe[title="YouTube Vorschau"]');
@@ -885,6 +1098,9 @@ test('validated YouTube and TikTok players switch without exposing private field
'src', 'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75' 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
); );
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
await expect(youtube).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await expect(output.locator('.output-connection')).toHaveClass(/sr-only/); await expect(output.locator('.output-connection')).toHaveClass(/sr-only/);
const publicState = await output.evaluate(async () => const publicState = await output.evaluate(async () =>
JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json())) JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json()))
@@ -918,10 +1134,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(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
const png = Buffer.from( const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
@@ -933,11 +1150,42 @@ test('live control persists state and exposes only explicit presentation data',
}); });
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click(); await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.goto('/sendungen/show-october');
const plannedConsole = page
.locator('.editable-usage')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.first();
await plannedConsole.getByRole('button', { name: /bearbeiten und verschieben/ }).click();
await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click();
await page.goto('/live?show=show-october'); await page.goto('/live?show=show-october');
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Bereit für die Sendung' })).toBeVisible();
await expect(page.locator('.sidebar')).toHaveCount(0);
await expect(page.locator('.mobile-header')).toHaveCount(0);
await expect(page.locator('.mobile-navigation')).toHaveCount(0);
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.getByRole('button', { name: 'Zurück', exact: true })).toBeDisabled();
await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/);
const liveMenuTrigger = page.getByRole('button', { name: 'Menü', exact: true });
await liveMenuTrigger.click();
const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' });
await expect(liveMenu).toBeVisible();
await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten');
await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await liveMenuTrigger.press('Enter');
await expect(liveMenu).toBeVisible();
await page.locator('.live-current').click({ position: { x: 8, y: 8 } });
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(page.locator('.live-output-state')).toContainText('Leer');
await page.getByText('Quellen · 1', { exact: true }).click();
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();
@@ -945,15 +1193,17 @@ test('live control persists state and exposes only explicit presentation data',
const before = await page const before = await page
.locator('.live-current') .locator('.live-current')
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
await page.getByText('Menü', { exact: true }).click();
await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
await page.reload(); await page.reload();
const restored = await page const restored = await page
.locator('.live-current') .locator('.live-current')
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
expect(restored).not.toBe(before); expect(restored).not.toBe(before);
await page.getByText('Manuelle Recherche öffnen').click(); await page.locator('.research-details > summary').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await page.getByText('Menü', { exact: true }).click();
const [popup] = await Promise.all([ const [popup] = await Promise.all([
page.waitForEvent('popup'), page.waitForEvent('popup'),
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click() page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
@@ -963,9 +1213,8 @@ test('live control persists state and exposes only explicit presentation data',
const anonymous = await browser.newContext(); const anonymous = await browser.newContext();
const output = await anonymous.newPage(); const output = await anonymous.newPage();
await output.goto(outputUrl); await output.goto(outputUrl);
await expect( await expect(output.locator('.output-content')).toHaveCount(0);
output.getByRole('heading', { name: 'Bereit für den nächsten Inhalt' }) await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
).toBeVisible();
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await expect( await expect(
@@ -973,14 +1222,16 @@ test('live control persists state and exposes only explicit presentation data',
).toBeVisible({ ).toBeVisible({
timeout: 10_000 timeout: 10_000
}); });
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible(); await expect(output.getByRole('heading', { name: 'Weniger Leistung, mehr Idee' })).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0); await expect(output.locator('.output-topics')).toHaveText('Gaming');
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible();
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');
expect(outputHtml).not.toContain('https://example.com/console');
const stateUrl = new URL(outputUrl); const stateUrl = new URL(outputUrl);
stateUrl.hash = ''; stateUrl.hash = '';
stateUrl.pathname += '/state'; stateUrl.pathname += '/state';
@@ -991,29 +1242,285 @@ test('live control persists state and exposes only explicit presentation data',
expect(outputJson).not.toContain('Herstellerangaben'); expect(outputJson).not.toContain('Herstellerangaben');
expect(outputJson).not.toContain('Moderationsstichpunkte'); expect(outputJson).not.toContain('Moderationsstichpunkte');
expect(outputJson).not.toContain('live-konsole.png'); expect(outputJson).not.toContain('live-konsole.png');
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await page.getByText('Reserve', { exact: true }).click();
await page.getByRole('button', { name: 'Als Nächstes' }).click(); await page.getByRole('button', { name: 'Als Nächstes' }).click();
await expect(page.locator('.next-card')).toContainText('Pixelart'); await expect(page.locator('.live-next-inline')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Erledigen und weiter' }).click(); await page.getByRole('button', { name: 'Erledigen & weiter' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart'); await expect(page.locator('.live-current')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Überspringen' }).click(); await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: 'Zurück' }).click(); await expect(page.locator('#current-title')).toHaveText(
await expect(page.locator('.live-current')).toContainText('Pixelart'); 'Warum praktische Effekte wieder auffallen'
);
await expect(
page.locator('.live-run-item').filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Erledigt');
await expect(
page.locator('.live-run-item').filter({ hasText: 'Pixelart, die nicht nostalgisch sein will' })
).toContainText('Übersprungen');
await expect(
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Aktuell');
await page.locator('.live-current-scroll').evaluate((element) => {
for (let index = 0; index < 12; index += 1) {
const paragraph = document.createElement('p');
paragraph.textContent = 'Temporärer Langtext für die Lesepositionsprüfung.';
element.append(paragraph);
}
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event('scroll'));
});
expect(
await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)
).toBeGreaterThan(0);
const countdownBeforeBack = await page.locator('.section-countdown strong').textContent();
await page.getByRole('button', { name: 'Zurück', exact: true }).click();
await expect(page.locator('#current-title')).toHaveText(
'Pixelart, die nicht nostalgisch sein will'
);
await expect(
page
.locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled();
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled();
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
const countdownAfterBack = await page.locator('.section-countdown strong').textContent();
expect(countdownAfterBack).not.toBe('05:00');
expect(countdownAfterBack?.slice(0, 3)).toBe(countdownBeforeBack?.slice(0, 3));
await page
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen'
})
.click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect
.poll(() => page.locator('.live-current-scroll').evaluate((element) => element.scrollTop))
.toBe(0);
await expect(
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Aktuell');
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page
.getByRole('button', {
name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen'
})
.click();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
await expect(output.getByText('Wenn das Licht wirklich auf ein Modell fällt.')).toHaveCount(0);
await page.setViewportSize({ width: 1440, height: 900 });
await setTheme(page, 'dark');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-controller-desktop-dark.png') });
await page.setViewportSize({ width: 900, height: 450 });
await setTheme(page, 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.evaluate(() => {
const title = document.querySelector('#current-title');
if (title)
title.textContent =
'Warum praktische Effekte in einer außergewöhnlich langen Live-Moderation wieder auffallen';
const copy = document.querySelector('.prompter-copy');
if (copy) {
for (let index = 0; index < 8; index += 1) {
const paragraph = document.createElement('p');
paragraph.textContent =
'Zusätzlicher langer Moderationstext prüft die unabhängige Scrollfläche ohne die feste Bedienleiste zu verdrängen.';
copy.append(paragraph);
}
}
const rail = document.querySelector('.live-run-scroll');
const section = rail?.querySelector('.rail-section');
if (rail && section) {
for (let index = 0; index < 8; index += 1) rail.append(section.cloneNode(true));
}
});
const compactLayout = await page.evaluate(() => {
const current = document.querySelector('.live-current')?.getBoundingClientRect();
const rail = document.querySelector('.live-rail')?.getBoundingClientRect();
const footer = document.querySelector('.live-action-bar')?.getBoundingClientRect();
const pageStyle = getComputedStyle(document.querySelector('.live-page') as HTMLElement);
const pieceScroll = document.querySelector('.live-current-scroll');
const railScroll = document.querySelector('.live-run-scroll');
return {
pageFits: document.documentElement.scrollHeight <= innerHeight,
twoColumns: Boolean(current && rail && rail.x > current.x + current.width - 1),
footerVisible: Boolean(footer && footer.bottom <= innerHeight && footer.top >= 0),
shortViewportDensity: parseFloat(pageStyle.gap) < 8,
unscaled: pageStyle.transform === 'none',
unzoomed: ['', '1', 'normal'].includes(pageStyle.getPropertyValue('zoom')),
pieceScrolls: Boolean(pieceScroll && pieceScroll.scrollHeight > pieceScroll.clientHeight),
railScrolls: Boolean(railScroll && railScroll.scrollHeight > railScroll.clientHeight)
};
});
expect(compactLayout).toEqual({
pageFits: true,
twoColumns: true,
footerVisible: true,
shortViewportDensity: true,
unscaled: true,
unzoomed: true,
pieceScrolls: true,
railScrolls: true
});
await page.screenshot({ path: testInfo.outputPath('live-controller-compact-desktop.png') });
await page.setViewportSize({ width: 670, height: 450 });
await page.evaluate(() => {
const section = document.querySelector('.live-context-title.section span');
const next = document.querySelector('.live-context-title.next span');
if (section)
section.textContent =
'Aktuelles Segment mit einem außergewöhnlich langen Namen für die kompakte Regie';
if (next)
next.textContent =
'Danach folgt ein Piece mit einem ebenfalls außergewöhnlich langen und vollständigen Titel';
});
const smallHeader = page.locator('.live-command-header');
const smallHeaderBox = await smallHeader.boundingBox();
expect(smallHeaderBox?.height ?? 999).toBeLessThan(105);
await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'nowrap');
await expect(page.locator('.live-context-title.next span')).toHaveCSS('white-space', 'nowrap');
await page.locator('.live-context-title.section').click();
await expect(page.locator('.live-context-title.section')).toHaveClass(/expanded/);
await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'normal');
await page.keyboard.press('Escape');
await expect(page.locator('.live-context-title.section')).not.toHaveClass(/expanded/);
await page.locator('.live-context-title.next').focus();
await page.locator('.live-context-title.next').press('Enter');
await expect(page.locator('.live-context-title.next')).toHaveClass(/expanded/);
await page.keyboard.press('Escape');
await liveMenuTrigger.click();
await expect(liveMenu).toBeVisible();
const compactMenuBox = await liveMenu.boundingBox();
expect(compactMenuBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect(compactMenuBox?.y ?? -1).toBeGreaterThanOrEqual(0);
expect(
Math.ceil((compactMenuBox?.x ?? 999) + (compactMenuBox?.width ?? 999))
).toBeLessThanOrEqual(670);
expect(
Math.ceil((compactMenuBox?.y ?? 999) + (compactMenuBox?.height ?? 999))
).toBeLessThanOrEqual(450);
await liveMenu.getByRole('link', { name: /Zur Planung/ }).scrollIntoViewIfNeeded();
await expect(liveMenu.getByRole('link', { name: /Zur Planung/ })).toBeVisible();
await liveMenu.getByRole('button', { name: 'Zeiten ausblenden' }).click();
await expect(page.locator('.live-piece-time')).toHaveCount(0);
await liveMenu.getByRole('button', { name: 'Zeiten einblenden' }).click();
await expect(page.locator('.live-piece-time')).toBeVisible();
await page.keyboard.press('Escape');
await expect(liveMenuTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-controller-670x450.png') });
const countdownBeforeReload = await page.locator('.section-countdown strong').textContent();
await page.reload();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
const countdownAfterReload = await page.locator('.section-countdown strong').textContent();
expect(countdownAfterReload).not.toBe('05:00');
expect(countdownAfterReload).toMatch(/0[34]:\d{2}/);
expect(countdownBeforeReload).toMatch(/0[34]:\d{2}/);
await page.setViewportSize({ width: 390, height: 700 });
const liveRail = page.locator('.live-rail');
const railClose = liveRail.getByRole('button', { name: 'Ablauf schließen' });
await expect(liveRail).toBeHidden();
await expect(page.locator('.live-action-bar')).toBeVisible();
await expectNoHorizontalOverflow(page);
expect(
await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight)
).toBe(true);
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') });
await liveMenuTrigger.click();
const mobileMenuBox = await liveMenu.boundingBox();
expect(Math.ceil((mobileMenuBox?.x ?? 999) + (mobileMenuBox?.width ?? 999))).toBeLessThanOrEqual(
390
);
expect(Math.ceil((mobileMenuBox?.y ?? 999) + (mobileMenuBox?.height ?? 999))).toBeLessThanOrEqual(
700
);
await page.keyboard.press('Escape');
await expect(liveMenuTrigger).toBeFocused();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await expect(liveRail).toHaveClass(/open/);
await expect(railClose).toBeFocused();
await expect
.poll(async () => {
const box = await liveRail.boundingBox();
return box ? Math.ceil(box.x + box.width) : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(390);
const mobileRailBox = await liveRail.boundingBox();
expect(mobileRailBox?.width ?? 0).toBeGreaterThan(340);
await expect(page.locator('.live-run-item.current')).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') });
await page.keyboard.press('Escape');
await expect(liveRail).toBeHidden();
await expect(page.getByRole('button', { name: /Ablauf ·/ })).toBeFocused();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await page.setViewportSize({ width: 900, height: 450 });
await expect(liveRail).not.toHaveAttribute('role', 'dialog');
await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', '');
await page.setViewportSize({ width: 390, height: 700 });
await expect(liveRail).toBeHidden();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen'
})
.click();
await expect(liveRail).toBeHidden();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail
.getByRole('button', {
name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen'
})
.click();
await expect(liveRail).toBeHidden();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
await page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click();
await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible();
await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await output.route('**/state', (route) => route.abort()); await output.route('**/state', (route) => route.abort());
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 }); await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });
await output.unroute('**/state'); await output.unroute('**/state');
await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 }); await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 });
await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: 'Präsentation leeren' }).click();
await expectNoHorizontalOverflow(page); await expect(output.locator('.output-content')).toHaveCount(0);
await page.screenshot({ await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
path: testInfo.outputPath('live-controller-mobile.png'),
fullPage: true
});
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.evaluate(() => { await page.evaluate(() => {
window.open = () => null; window.open = () => null;
}); });
await page.getByText('Menü', { exact: true }).click();
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await expect( await expect(
page page
@@ -1036,7 +1543,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
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', { exact: true }).fill('E2E Fundstück mit Markdown'); await editInlineTitle(page, 'Neues Fundstück', 'E2E Fundstück mit Markdown');
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: 'Editor schließen' }).click(); await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
@@ -1048,6 +1555,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
.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.getByText('Themen auswählen', { exact: true }).click();
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page await page
@@ -1078,7 +1586,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0); await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau'); await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
await page.reload(); await page.reload();
await expect(page.getByLabel('Titel', { exact: true })).toHaveValue('E2E Fundstück mit Markdown'); await expect(
page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' })
).toBeVisible();
await page.getByText('Status, Dauer und Einordnung').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, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
@@ -1090,6 +1600,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
); );
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Bild', { exact: true }).setInputFiles({ await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png', name: 'redaktionsbild.png',
mimeType: 'image/png', mimeType: 'image/png',
@@ -1110,7 +1621,7 @@ 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', { exact: true }).fill('E2E Fundstück, extern aktualisiert'); await editInlineTitle(page, 'E2E Fundstück mit Markdown', '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.getByText('Status, Dauer und Einordnung').click(); await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent await concurrent
@@ -1120,9 +1631,9 @@ 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', { exact: true })).toHaveValue( await expect(
'E2E Fundstück, extern aktualisiert' concurrent.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' })
); ).toBeVisible();
await concurrent.getByText('Status, Dauer und Einordnung').click(); 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.'
@@ -1145,12 +1656,18 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
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 .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', { exact: true })).toHaveValue( await expect(
'Kopie von E2E Fundstück, extern aktualisiert' page.getByRole('button', {
); name: 'Kopie von E2E Fundstück, extern aktualisiert Titel bearbeiten'
})
).toBeVisible();
await page.context().setOffline(true); await page.context().setOffline(true);
await page.getByLabel('Titel', { exact: true }).fill('Kopie mit unterbrochener Verbindung'); await editInlineTitle(
page,
'Kopie von E2E Fundstück, extern aktualisiert',
'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
}); });
@@ -1229,15 +1746,13 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).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('Kartentitel').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', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue( await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau');
'Manuell korrigierte Linkvorschau'
);
const previewPromise = page.waitForEvent('popup'); const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click(); await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
@@ -1248,7 +1763,7 @@ test('link preview uses one existing source, persists manual fields and isolates
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' }) preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible(); ).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible(); await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(0); await expect(preview.locator('.output-link-image')).toHaveCount(1);
await preview.screenshot({ await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'), path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true fullPage: true