2 Commits
Author SHA1 Message Date
hnzio 5a9d7735d4 Release 0.5.0 2026-10-22 00:28:49 +02:00
hnzio 4042fe6a64 Release 0.4.0 2026-10-21 23:52:05 +02:00
35 changed files with 5376 additions and 354 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.3.0", "version": "0.5.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+39 -3
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
Etappe 1 bis 3: technisches Grundgerüst, Anmeldung und Rollen sowie einen Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen
persistenten Fundus mit Eingang, Editor, Taxonomie, privaten Bildern und persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine
Konfliktschutz. Sendungsbau und Live-Steuerung folgen in späteren Etappen. abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6.
## Voraussetzungen ## Voraussetzungen
@@ -83,6 +83,42 @@ Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müs
als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten
Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar. Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar.
## Sendungen planen
Unter **Sendungen** lässt sich ein neuer Stream leer, mit dem Standardablauf
oder als Spieleabend anlegen. Abschnitte sind frei benennbar und können eine
explizite Dauer erhalten; andernfalls ergibt sich ihre Dauer aus den
eingeplanten Pieces. Die Reservebank zählt nie zur Planzeit.
Der sendefertige Fundus steht neben dem Ablauf. Pieces können mehrfach
eingeplant und je Verwendung mit eigenem Titel, eigener Dauer und eigenen
Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Abschnitte
und Verwendungen lassen sich per Drag-and-drop sowie gleichwertig mit sichtbaren
Schaltflächen und Auswahlfeldern verschieben. Twitch-Titel und Ankündigung sind
kopierbar. Gleichzeitige Änderungen werden über die Revision der gesamten
Sendung erkannt und nicht still überschrieben.
## Live moderieren und präsentieren
Die Live-Steuerung öffnet sich aus einer geplanten Sendung. Sie zeigt
Einleitung, Moderationsstichpunkte und Überleitung in einstellbarer
Prompter-Schrift; die private Recherche bleibt standardmäßig geschlossen.
Zeiten und Ablaufleiste können ausgeblendet werden. Schriftgröße, Sichtbarkeit
und Leseposition bleiben im Browser erhalten.
Start, Erledigen, Überspringen und Zurück werden persistent mit tatsächlichen
Zeitintervallen gespeichert. Wiederholte Befehle erzeugen keine doppelte
Zeitbuchung. Offene Pieces und die Reserve lassen sich während der Sendung
sortieren beziehungsweise als nächster Beitrag einfügen.
Das separate Präsentationsfenster unter `/heinzlive/...` verwendet einen langen,
widerrufbaren Zufallstoken. Es ändert sich ausschließlich durch die Aktion
**Präsentieren** und erhält nur Präsentationstext, öffentliche Quellen und die
zum präsentierten Piece gehörenden Bilder. Recherche, Moderationsnotizen,
eigene Notizen, Kontodaten und interne Dateipfade werden weder im HTML noch im
JSON-Zustand ausgeliefert. Der Token ist ein Zugangsgeheimnis und darf nicht
weitergegeben oder protokolliert werden.
## Befehle ## Befehle
| Befehl | Zweck | | Befehl | Zweck |
+57
View File
@@ -91,3 +91,60 @@
ungewollten horizontalen Überlauf ungewollten horizontalen Überlauf
- Mobile Darkmode-Aufnahme des Piece-Editors visuell kontrolliert - Mobile Darkmode-Aufnahme des Piece-Editors visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich - `bash -n start.sh` und `git diff --check`: erfolgreich
## Etappe 4
| Kriterium | Nachweis | Stand |
| ----------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------- |
| Spieleabend und mehrere JC-/Spieleblöcke | persistierte Spieleabend-Vorlage, Seed-Sendung und Browserrundlauf mit Standardablauf | erfüllt am 21.10.2026 |
| Reihenfolge und Texte bleiben nach Reload | Verhaltenstests sowie Browsertest für Tastatur- und Drag-and-drop-Sortierung | erfüllt am 21.10.2026 |
| Original bleibt unverändert | Service- und Browsertest planen dasselbe Piece mehrfach mit eigenen Titeln, Notizen, Dauern | erfüllt am 21.10.2026 |
| Keine doppelte Dauer; Reserve zählt nicht | explizite und summierte Abschnitte sowie Reserve in Verhaltenstest und Browser geprüft | erfüllt am 21.10.2026 |
| Entfernen betrifft nur Einplanung | Verhaltenstest löscht Verwendung und Abschnittskopie, nicht das Original | erfüllt am 21.10.2026 |
| Sendungsänderung verändert keine Vorlage | tiefe Vorlagenkopie vor und nach Abschnittsänderung verglichen | erfüllt am 21.10.2026 |
| Smartphone-Planungsablauf | Abschnittsanlage, Pane-Umschaltung und Überlauf bei 390 Pixeln geprüft | erfüllt am 21.10.2026 |
| Gleichwertige Bedienwege | Drag-and-drop, Enter auf Verschiebeschaltfläche sowie klick-/touchbare Zielauswahl geprüft | erfüllt am 21.10.2026 |
| Lokale Planungsaktualisierung | Quelle und Reserve per Drag-and-drop ohne Dokumentreload; lokaler Speicherzustand geprüft | erfüllt am 21.10.2026 |
| Sichtbare Einfügeposition | Abschnitt, Fundusquelle und Reserve zeigen vor dem Drop Vorschau und transparentes Original | erfüllt am 21.10.2026 |
| Piece-Dauer in Minuten | Fundus- und Verwendungseditor rechnen Minuten serverseitig in ganze Sekunden um | erfüllt am 21.10.2026 |
| Gleichzeitige Änderungen | veraltete showweite Revision rollt die gesamte Mutation zurück | erfüllt am 21.10.2026 |
## Ausgeführte Prüfungen Etappe 4
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 18 Tests in 4 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich
- Browserprüfung bei 390, 768, 960, 1280 und 1440 Pixeln in beiden Themes ohne
ungewollten horizontalen Überlauf
- Mobile Aufnahme des vollständigen Sendungsbaukastens visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Etappe 5
| Kriterium | Nachweis | Stand |
| ------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------- |
| Lesbare Moderation | Prompter-Schrift, Einleitung und Überleitung im Browser; Recherche initial geschlossen | erfüllt am 22.10.2026 |
| Wiederherstellung | Live-Stand, Laufintervalle, Schriftgröße und Leseposition nach Neuladen geprüft | erfüllt am 22.10.2026 |
| Keine doppelte Zeiterfassung | Verhaltenstest wiederholt dieselbe Befehls-ID und findet genau ein Zeitintervall | erfüllt am 22.10.2026 |
| Explizite Präsentation | Zweitfenster bleibt nach Start leer und ändert sich erst durch „Präsentieren“ | erfüllt am 22.10.2026 |
| Getrennte sichere Ausgabe | anonymer Browserkontext prüft Text, Bild, HTML, JSON und fremde Medien ohne private Daten | erfüllt am 22.10.2026 |
| Reserve und Reihenfolge | Reserve als nächstes Piece eingefügt; offene Reihenfolge und serverseitige Live-Grenze geprüft | erfüllt am 22.10.2026 |
| Popup- und Verbindungsfehler sichtbar | blockiertes Popup sowie unterbrochenes und wiederhergestelltes State-Polling im Browser geprüft | erfüllt am 22.10.2026 |
| Reale OBS-Browserquelle | lokale tokenisierte Browserausgabe vorhanden; kein OBS auf der Prüfmaschine | offen |
## Ausgeführte Prüfungen Etappe 5
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 20 Tests in 5 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich
- Anonymer zweiter Chromium-Kontext mit Präsentationstext, Quelle, geschütztem
Bild und gefiltertem JSON geprüft
- Mobile Aufnahme der Live-Steuerung bei 390 × 844 Pixeln und Desktopaufnahme
der heinzlive-Ausgabe visuell kontrolliert
- Popup-Blockade sowie Verbindungsabbruch und automatische Wiederverbindung im
Browser geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
+83 -21
View File
@@ -31,23 +31,24 @@ spätere API dieselben Regeln verwenden.
- Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative - Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative
ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`. ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`.
## Geplantes Gesamtdatenmodell ## Gesamtdatenmodell
Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt. Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | | Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte | | Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz | | Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | | Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | | 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
@@ -102,6 +103,9 @@ Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die
Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien. Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien.
Piece-Dauern werden in den Fundus- und Verwendungseditoren als Minuten erfasst,
an der Servergrenze auf volle Sekunden gerundet und ausschließlich als ganze
Sekunden persistiert.
Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit
sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über
@@ -111,6 +115,67 @@ 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.
## Sendungsbau
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine
Sendung ist die Konflikt- und Transaktionsgrenze: Jede Änderung an Metadaten,
Abschnitten, Verwendungen oder Reihenfolge vergleicht die erwartete Revision und
erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt.
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
Überleitung und Präsentation aus dem sendefertigen Piece kopiert. Eigene Notizen
liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für
Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt.
Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch
verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die
Reserve in der öffentlichen Struktur und allen Dauerberechnungen getrennt
bleibt. Positionen werden nach jeder Transaktion kollisionsfrei normalisiert.
Eine explizite Abschnittsdauer ersetzt die Summe der Verwendungen vollständig.
Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern.
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Dadurch bleibt das Dokument bei
Planungsmutationen bestehen; nur die serverseitigen Seitendaten werden
aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen Abschnitt
oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen entweder
eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben
serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des
Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige
Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete
Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst
beim Loslassen.
## Live-Modus und Präsentationsgrenze
`src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der
Live-Stand besitzt eine eigene Revision. Jeder steuernde Befehl trägt zusätzlich
eine vom Browser erzeugte Befehls-ID; `(show_id, command_id)` ist eindeutig und
liefert bei Wiederholung das bereits gespeicherte Ergebnis. Tatsächliche Zeiten
liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
geplanten Dauer nachvollziehbar.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe
`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation
oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein
neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht.
Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per
`no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler
sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
@@ -120,12 +185,9 @@ 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.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht. Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
Etappe 1 zeigt Fundus, Sendungsbau und Live-Modus als sichtbare
`Gestaltungsvorschau`. Die Daten kommen bereits aus SQLite, mutierende
Fachabläufe werden jedoch nicht vorgetäuscht.
## Sicherheitsgrenzen ab den Folgeetappen ## Sicherheitsgrenzen ab den Folgeetappen
+53
View File
@@ -172,3 +172,56 @@ mit sicherheitsrelevanten Headern aus. Das Adapter-Requestlimit liegt mit 6 MB
knapp oberhalb des fachlichen Dateilimits. knapp oberhalb des fachlichen Dateilimits.
Quelle: <https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload> Quelle: <https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload>
## D-014: Sendung als transaktionales Aggregat
Eine ganzzahlige Revision auf `shows` schützt in Etappe 4 sämtliche Metadaten,
Abschnitte, Verwendungen und Reihenfolgen gemeinsam. Jede Mutation erhöht die
Revision in derselben SQLite-Transaktion. Damit können voneinander abhängige
Listenänderungen nicht teilweise gespeichert oder durch einen veralteten
Browserstand unbemerkt überschrieben werden.
Verwendungen speichern vollständige redaktionelle Text- und Dauerkopien. Die
Original-ID bleibt Referenz, ist aber keine Live-Datenquelle. Eine interne,
eindeutige Reservesection pro Sendung erlaubt dieselben geprüften Verschiebe-
und Löschoperationen für Plan und Reserve; Dauerabfragen schließen sie aus.
## D-015: Drag-and-drop nur als zusätzliche Bedienung
Abschnitte, Verwendungen und gefilterte Quellen können über eindeutige Griffe
gezogen werden. Dieselben Ergebnisse sind mit sichtbaren
Hoch-/Runter-Schaltflächen und Zielauswahl erreichbar. Diese Bedienelemente
funktionieren mit einfachem Klick, Touch und Tastatur auch ohne Drag-and-drop
oder clientseitige Spezialgesten. Das folgt WCAG 2.2 SC 2.5.7;
Tastaturzugang allein wäre keine ausreichende Zeigeralternative. Die
zugehörigen Form-Actions werden progressiv verbessert: Ohne JavaScript bleiben
normale POSTs möglich, mit JavaScript erfolgt die Aktualisierung ohne
Dokumentreload und mit lokalem Speicherzustand.
Quellen:
- <https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements>
- <https://svelte.dev/docs/kit/form-actions>
## D-016: Live-Befehle idempotent, tatsächliche Zeiten als Intervalle
Die Live-Steuerung verwendet eine eigene Revision und zusätzlich eine pro
Bedienaktion erzeugte Befehls-ID. Die Datenbank akzeptiert jede Kombination aus
Sendung und Befehls-ID nur einmal. Ein nach Verbindungsabbruch wiederholter
Befehl liefert dieselbe Ergebnisrevision und erzeugt weder einen zweiten
Statuswechsel noch ein zweites Zeitintervall. Laufzeit wird nicht als einzelner
überschreibbarer Wert, sondern als Summe abgeschlossener und höchstens eines
aktiven Intervalls ermittelt. Dadurch bleiben Zurückgehen und Wiederaufnahme
korrekt.
## D-017: Präsentationsausgabe als Positivliste mit gehashtem Zugang
Die heinzlive-Ausgabe ist kein eingeschränkter Aufruf der internen Live-Ansicht,
sondern eine eigene Datenprojektion. Zulässig sind ausschließlich ausdrücklich
präsentierter Titel und Präsentationstext, öffentliche Quellen sowie zugehörige
Bilder und Alternativtexte. Recherche, Einleitung, Moderation, eigene Notizen,
Ablauf, Nutzer und interne Dateinamen werden nicht in HTML oder JSON übernommen.
Ein zufälliger 256-Bit-Token steht in der URL, während SQLite nur seinen
SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und
Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`-
Polling statt über eine dauerhaft offene Serververbindung.
+52 -8
View File
@@ -2,9 +2,10 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 bis Etappe 3 sind abgeschlossen. Architektur, Gesamtdatenmodell, Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung und lokaler Betriebsweg sind Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
dokumentiert. Etappe 4 wurde nicht begonnen. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1 ## Umgesetzter Stand Etappe 1
@@ -37,9 +38,8 @@ umgebrochen.
## Noch nicht umgesetzt ## Noch nicht umgesetzt
Sendungsplanung, echte Live-Steuerung, Präsentationsfenster, Archivabschluss, Archivabschluss, Freigaben, RSS und API. Diese späteren Bereiche dürfen nicht
Freigaben, RSS und API. Die bestehenden Vorschauen dürfen nicht als als bereits implementiert verstanden werden.
Implementierung dieser späteren Funktionen verstanden werden.
## Umgesetzter Stand Etappe 2 ## Umgesetzter Stand Etappe 2
@@ -56,8 +56,8 @@ Implementierung dieser späteren Funktionen verstanden werden.
- persistente Piece-Neuanlage aus leerem Entwurf oder drei redaktionellen Vorlagen - persistente Piece-Neuanlage aus leerem Entwurf oder drei redaktionellen Vorlagen
- Schnellaufnahme von Gedanken und HTTP-/HTTPS-Links im Eingang - Schnellaufnahme von Gedanken und HTTP-/HTTPS-Links im Eingang
- vollständiger Editor für Quellen, Dauer, Status und getrennte Einleitung, - vollständiger Editor für Quellen, Dauer in Minuten, Status und getrennte
Recherche, Moderation, Überleitung und Präsentation Einleitung, Recherche, Moderation, Überleitung und Präsentation
- serverseitig bereinigte Markdown-Vorschau - serverseitig bereinigte Markdown-Vorschau
- unabhängige Themen, Formate und Abschnittskategorien mit Barkeeper-Verwaltung - unabhängige Themen, Formate und Abschnittskategorien mit Barkeeper-Verwaltung
- serverseitige Suche und Filter nach Status, Format, Thema und Abschnitt - serverseitige Suche und Filter nach Status, Format, Thema und Abschnitt
@@ -68,6 +68,45 @@ Implementierung dieser späteren Funktionen verstanden werden.
- private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung - private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung
- erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen - erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen
## Umgesetzter Stand Etappe 4
- Sendungsübersicht mit leerem Start, Standardablauf und Spieleabend-Vorlage
- frei benennbare, kategorisierte und sortierbare Abschnitte einschließlich
eigenständiger Spieleblöcke
- Einplanung ausschließlich sendefertiger Pieces als unabhängige Text- und
Dauerkopien; Mehrfachverwendung desselben Originals ist möglich
- showweite Reservebank, die nicht in Abschnitts- oder Gesamtdauer einfließt
- explizite Abschnittsdauer ersetzt die Piece-Summe statt sie zu addieren
- Bearbeiten, Entfernen sowie Verschieben innerhalb und zwischen Abschnitten
- Piece-Dauern werden im Editor in Minuten eingegeben und intern weiterhin als
ganze Sekunden gespeichert
- Drag-and-drop über eindeutige Griffe, auch direkt aus gefilterten Quellen,
mit positionsgenauer Einfügevorschau, transparentem Original und
gleichwertigen Klick-, Touch- und Tastaturaktionen
- Planungsmutationen ohne Dokumentreload mit lokalem Speicherzustand und
animierter Neusortierung der betroffenen Elemente
- kopierbare Twitch-Titel und Ankündigungstexte
- showweite optimistische Revision schützt Metadaten, Reihenfolge und Inhalte
vor unbemerktem parallelem Überschreiben
- realistischer Seed mit Standardablauf und eigenständigem Spieleabend
## Umgesetzter Stand Etappe 5
- fokussierte Moderationsansicht mit Prompter-Schrift, Einleitung, Moderation,
Überleitung und standardmäßig geschlossener privater Recherche
- ausblendbare Zeit- und Ablaufanzeige sowie persistente lokale Leseposition
- aktuelles und nächstes Piece samt Abschnitt und geplantem Zeitbedarf
- persistenter Live-Stand mit tatsächlichen Laufintervallen, Erledigen,
Überspringen, Zurück und Wiederaufnahme
- idempotente Live-Befehle verhindern doppelte Wirkung bei Wiederholung
- serverseitig geschützte Umsortierung offener Pieces und Reserveeinfügung
- explizite Präsentationsaktion; normale Live-Navigation verändert die Ausgabe
nicht
- getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige,
Präsentationstext, Quellen und erlaubten Bildern
- Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche,
Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade
## Bekannte offene Betriebsprüfung ## Bekannte offene Betriebsprüfung
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
@@ -76,6 +115,11 @@ durchgeführt werden. Auch der lokale Container-Smoke-Test blieb offen, weil
auf der Prüfmaschine kein Docker-Daemon lief. Manifestkernfelder und auf der Prüfmaschine kein Docker-Daemon lief. Manifestkernfelder und
Startskript-Syntax wurden lokal geprüft. Startskript-Syntax wurden lokal geprüft.
Eine reale OBS-Browserquelle stand nicht zur Verfügung. Die tokenisierte
Präsentationsausgabe wurde stattdessen in einem anonymen zweiten Chromium-
Browserkontext einschließlich Bild, JSON-Filterung, Polling und sichtbarem
Verbindungsabbruch geprüft.
## Verifizierte Umgebung ## Verifizierte Umgebung
- Node.js 22.23.1, npm 10.9.8 - Node.js 22.23.1, npm 10.9.8
+152
View File
@@ -0,0 +1,152 @@
CREATE TABLE shows_v4 (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
starts_at_utc TEXT,
twitch_title TEXT NOT NULL DEFAULT '',
announcement_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'live', 'completed')),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE show_sections_v4 (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows_v4(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
explicit_duration_seconds INTEGER CHECK (
explicit_duration_seconds IS NULL OR
(explicit_duration_seconds >= 0 AND explicit_duration_seconds <= 86400)
),
is_reserve_bank INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve_bank IN (0, 1)),
UNIQUE (show_id, position)
);
CREATE TABLE piece_usages_v4 (
id TEXT PRIMARY KEY,
show_section_id TEXT NOT NULL REFERENCES show_sections_v4(id) ON DELETE CASCADE,
original_piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
status TEXT NOT NULL CHECK (status IN ('open', 'current', 'done', 'skipped')),
duration_seconds INTEGER NOT NULL CHECK (duration_seconds >= 0 AND duration_seconds <= 86400),
title_snapshot TEXT NOT NULL,
intro_snapshot TEXT NOT NULL DEFAULT '',
research_snapshot TEXT NOT NULL DEFAULT '',
moderation_snapshot TEXT NOT NULL DEFAULT '',
transition_snapshot TEXT NOT NULL DEFAULT '',
presentation_snapshot TEXT NOT NULL DEFAULT '',
own_notes TEXT NOT NULL DEFAULT '',
is_reserve INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve IN (0, 1)),
UNIQUE (show_section_id, position)
);
INSERT INTO shows_v4 (
id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc
)
SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, 1,
created_at_utc, updated_at_utc
FROM shows;
INSERT INTO show_sections_v4 (
id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank
)
SELECT id, show_id, name, category_id, position, explicit_duration_seconds, 0
FROM show_sections;
INSERT INTO show_sections_v4 (
id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank
)
SELECT 'reserve-bank-' || id, id, 'Reservebank', NULL,
COALESCE((SELECT max(position) + 1 FROM show_sections WHERE show_id = shows.id), 0), NULL, 1
FROM shows;
INSERT INTO piece_usages_v4 (
id, show_section_id, original_piece_id, position, status, duration_seconds,
title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve
)
SELECT
u.id,
CASE WHEN u.is_reserve = 1 THEN 'reserve-bank-' || s.show_id ELSE u.show_section_id END,
u.original_piece_id,
CASE WHEN u.is_reserve = 1 THEN
(
SELECT count(*) - 1
FROM piece_usages earlier
JOIN show_sections earlier_section ON earlier_section.id = earlier.show_section_id
WHERE earlier.is_reserve = 1 AND earlier_section.show_id = s.show_id
AND (earlier_section.position < s.position OR
(earlier_section.position = s.position AND earlier.position < u.position) OR
(earlier_section.position = s.position AND earlier.position = u.position AND earlier.id <= u.id))
)
ELSE u.position END,
u.status,
u.duration_seconds,
u.title_snapshot,
u.intro_snapshot,
COALESCE(p.research_text, ''),
u.moderation_snapshot,
u.transition_snapshot,
u.presentation_snapshot,
'',
u.is_reserve
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
LEFT JOIN pieces p ON p.id = u.original_piece_id;
DROP TABLE piece_usages;
DROP TABLE show_sections;
DROP TABLE shows;
ALTER TABLE shows_v4 RENAME TO shows;
ALTER TABLE show_sections_v4 RENAME TO show_sections;
ALTER TABLE piece_usages_v4 RENAME TO piece_usages;
CREATE INDEX idx_shows_status_start ON shows(status, starts_at_utc);
CREATE INDEX idx_sections_show ON show_sections(show_id, position);
CREATE INDEX idx_usages_section ON piece_usages(show_section_id, position);
CREATE UNIQUE INDEX idx_one_reserve_bank_per_show
ON show_sections(show_id) WHERE is_reserve_bank = 1;
CREATE TABLE show_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE show_template_sections (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES show_templates(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
explicit_duration_seconds INTEGER CHECK (
explicit_duration_seconds IS NULL OR
(explicit_duration_seconds >= 0 AND explicit_duration_seconds <= 86400)
),
UNIQUE (template_id, position)
);
CREATE INDEX idx_show_template_sections_template
ON show_template_sections(template_id, position);
INSERT INTO show_templates (id, name, description) VALUES
('template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'),
('template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.');
INSERT INTO show_template_sections (
id, template_id, name, category_id, position, explicit_duration_seconds
) VALUES
('template-default-start', 'template-default', 'Start', NULL, 0, 300),
('template-default-jc-reaction', 'template-default', 'JC / Reaction', NULL, 1, NULL),
('template-default-game-1', 'template-default', 'Spiel 1', NULL, 2, 3600),
('template-default-jc', 'template-default', 'JC', NULL, 3, NULL),
('template-default-game-2', 'template-default', 'Spiel 2', NULL, 4, 3600),
('template-games-start', 'template-games', 'Start', NULL, 0, 300),
('template-games-game-1', 'template-games', 'Spiel 1', NULL, 1, 2700),
('template-games-break', 'template-games', 'Pause & Chat', NULL, 2, 600),
('template-games-game-2', 'template-games', 'Spiel 2', NULL, 3, 2700),
('template-games-game-3', 'template-games', 'Spiel 3', NULL, 4, 2700);
+45
View File
@@ -0,0 +1,45 @@
CREATE TABLE show_live_states (
show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
current_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL,
started_at_utc TEXT,
ended_at_utc TEXT,
presentation_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL,
presentation_revision INTEGER NOT NULL DEFAULT 0 CHECK (presentation_revision >= 0),
updated_at_utc TEXT NOT NULL
);
CREATE TABLE live_usage_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
usage_id TEXT NOT NULL REFERENCES piece_usages(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_active_live_run_per_show
ON live_usage_runs(show_id) WHERE ended_at_utc IS NULL;
CREATE INDEX idx_live_runs_usage ON live_usage_runs(usage_id, started_at_utc);
CREATE TABLE live_commands (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
action TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 0),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
CREATE TABLE presentation_access_tokens (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at_utc TEXT NOT NULL,
revoked_at_utc TEXT
);
CREATE INDEX idx_presentation_tokens_show
ON presentation_access_tokens(show_id, revoked_at_utc);
UPDATE piece_usages SET status = 'open' WHERE status = 'current';
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.3.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.3.0", "version": "0.5.0",
"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.3.0", "version": "0.5.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+106 -7
View File
@@ -229,6 +229,38 @@ export function seed(databasePath = getPaths().database) {
] ]
); );
replace(
'show_templates',
['id', 'name', 'description'],
[
['template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'],
['template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.']
]
);
replace(
'show_template_sections',
['id', 'template_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[
['template-default-start', 'template-default', 'Start', 'cat-jc', 0, 300],
[
'template-default-jc-reaction',
'template-default',
'JC / Reaction',
'cat-reaction',
1,
null
],
['template-default-game-1', 'template-default', 'Spiel 1', 'cat-game', 2, 3600],
['template-default-jc', 'template-default', 'JC', 'cat-jc', 3, null],
['template-default-game-2', 'template-default', 'Spiel 2', 'cat-game', 4, 3600],
['template-games-start', 'template-games', 'Start', 'cat-jc', 0, 300],
['template-games-game-1', 'template-games', 'Spiel 1', 'cat-game', 1, 2700],
['template-games-break', 'template-games', 'Pause & Chat', 'cat-jc', 2, 600],
['template-games-game-2', 'template-games', 'Spiel 2', 'cat-game', 3, 2700],
['template-games-game-3', 'template-games', 'Spiel 3', 'cat-game', 4, 2700]
]
);
replace( replace(
'shows', 'shows',
[ [
@@ -238,6 +270,7 @@ export function seed(databasePath = getPaths().database) {
'twitch_title', 'twitch_title',
'announcement_text', 'announcement_text',
'status', 'status',
'revision',
'created_at_utc', 'created_at_utc',
'updated_at_utc' 'updated_at_utc'
], ],
@@ -249,6 +282,18 @@ export function seed(databasePath = getPaths().database) {
'Kleine Welten, große Ideen | heinzlive', 'Kleine Welten, große Ideen | heinzlive',
'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.', 'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.',
'ready', 'ready',
1,
timestamp,
timestamp
],
[
'show-games-evening',
'Spieleabend: Kurbeln & Pixel',
'2026-10-16T18:00:00.000Z',
'Kurbeln, Pixel und ein langer Spieleabend | heinzlive',
'Heute spielen wir uns von ungewöhnlicher Hardware bis zu moderner Pixelart.',
'draft',
1,
timestamp, timestamp,
timestamp timestamp
] ]
@@ -257,11 +302,25 @@ export function seed(databasePath = getPaths().database) {
replace( replace(
'show_sections', 'show_sections',
['id', 'show_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[ [
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300], 'id',
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null], 'show_id',
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600] 'name',
'category_id',
'position',
'explicit_duration_seconds',
'is_reserve_bank'
],
[
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300, 0],
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null, 0],
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600, 0],
['reserve-bank-show-october', 'show-october', 'Reservebank', null, 3, null, 1],
['games-start', 'show-games-evening', 'Start', 'cat-jc', 0, 300, 0],
['games-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0],
['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 0],
['games-second', 'show-games-evening', 'Spiel 2', 'cat-game', 3, 2700, 0],
['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1]
] ]
); );
@@ -276,9 +335,11 @@ export function seed(databasePath = getPaths().database) {
'duration_seconds', 'duration_seconds',
'title_snapshot', 'title_snapshot',
'intro_snapshot', 'intro_snapshot',
'research_snapshot',
'moderation_snapshot', 'moderation_snapshot',
'transition_snapshot', 'transition_snapshot',
'presentation_snapshot', 'presentation_snapshot',
'own_notes',
'is_reserve' 'is_reserve'
], ],
[ [
@@ -287,13 +348,15 @@ export function seed(databasePath = getPaths().database) {
'section-stories', 'section-stories',
'piece-console', 'piece-console',
0, 0,
'current', 'open',
480, 480,
'Die kleine Konsole mit dem großen Dreh', 'Die kleine Konsole mit dem großen Dreh',
'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?', 'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?',
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.', 'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.',
'Von der kleinen Konsole geht es zur ganz großen Leinwand.', 'Von der kleinen Konsole geht es zur ganz großen Leinwand.',
'Weniger Leistung, mehr Idee.', 'Weniger Leistung, mehr Idee.',
'',
0 0
], ],
[ [
@@ -305,24 +368,60 @@ export function seed(databasePath = getPaths().database) {
720, 720,
'Warum praktische Effekte wieder auffallen', 'Warum praktische Effekte wieder auffallen',
'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.', 'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.',
'',
'Beispiele vergleichen, nicht analog gegen digital ausspielen.', 'Beispiele vergleichen, nicht analog gegen digital ausspielen.',
'Nach so viel Handarbeit wird es spielerisch.', 'Nach so viel Handarbeit wird es spielerisch.',
'Wenn das Licht wirklich auf ein Modell fällt.', 'Wenn das Licht wirklich auf ein Modell fällt.',
'',
0 0
], ],
[ [
'usage-reserve', 'usage-reserve',
'section-stories', 'reserve-bank-show-october',
'piece-pixels', 'piece-pixels',
2, 0,
'open', 'open',
540, 540,
'Pixelart, die nicht nostalgisch sein will', 'Pixelart, die nicht nostalgisch sein will',
'Pixel sind längst keine technische Notlösung mehr.', 'Pixel sind längst keine technische Notlösung mehr.',
'',
'Großaufnahmen vorbereiten, drei Stilmittel benennen.', 'Großaufnahmen vorbereiten, drei Stilmittel benennen.',
'Genug Theorie, jetzt spielen wir selbst.', 'Genug Theorie, jetzt spielen wir selbst.',
'Pixel als bewusste Entscheidung.', 'Pixel als bewusste Entscheidung.',
'Ersatz, falls ein Spiel technisch ausfällt.',
1 1
],
[
'usage-games-console',
'games-first',
'piece-console',
0,
'open',
480,
'Die kleine Konsole mit dem großen Dreh',
'Wir beginnen mit Hardware, die ihr Spielprinzip gleich mitbringt.',
'Kurbelmechanik und verfügbare Spiele prüfen.',
'Konsole zeigen und Publikum nach Lieblingsmechaniken fragen.',
'Danach wechseln wir von Mechanik zu Grafik.',
'Eine Kurbel verändert, wie wir spielen.',
'Controller vor Sendungsbeginn laden.',
0
],
[
'usage-games-pixels',
'games-second',
'piece-pixels',
0,
'open',
540,
'Pixelart, die nicht nostalgisch sein will',
'Unser zweites Spiel nutzt Pixel nicht als Rückblick.',
'Drei aktuelle Beispiele vergleichen.',
'Auf Licht, Bewegung und Materialwirkung achten.',
'Zum Schluss sammeln wir eure Empfehlungen.',
'Pixel als bewusste Entscheidung.',
'',
0
] ]
] ]
); );
+842
View File
@@ -1101,6 +1101,273 @@ a {
font-size: 0.61rem; font-size: 0.61rem;
} }
.live-state-line {
margin: 0.4rem 0 0;
color: var(--text-muted);
font-size: 0.75rem;
}
.live-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
}
.live-session-summary {
display: grid;
margin-left: auto;
text-align: right;
}
.live-session-summary strong {
font-size: 0.78rem;
}
.live-session-summary span {
color: var(--text-muted);
font-size: 0.65rem;
}
.live-grid.without-rail {
grid-template-columns: minmax(0, 1fr);
}
.live-markdown > :first-child,
.prompter-copy > :first-child {
margin-top: 0;
}
.live-markdown > :last-child,
.prompter-copy > :last-child {
margin-bottom: 0;
}
.live-current-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.4rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
}
.live-current-actions form:last-child {
margin-left: auto;
}
.live-run-item,
.live-reserve-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
margin-top: 0.35rem;
padding: 0.5rem 0.55rem;
border-radius: 0.65rem;
background: var(--panel-soft);
}
.live-run-item.current {
box-shadow: inset 3px 0 var(--accent);
background: var(--accent-soft);
}
.live-run-item > div:first-child,
.live-reserve-item > div:first-child {
display: grid;
min-width: 0;
}
.live-run-item strong,
.live-reserve-item strong {
font-size: 0.7rem;
overflow-wrap: anywhere;
}
.live-run-item small,
.live-reserve-item small {
color: var(--text-muted);
font-size: 0.61rem;
}
.live-run-actions {
display: flex;
align-items: center;
gap: 0.2rem;
}
.live-run-actions form {
display: flex;
}
.live-run-actions button:not(.text-button) {
min-width: 1.65rem;
min-height: 1.65rem;
padding: 0;
}
.live-run-actions .text-button {
padding: 0.3rem;
font-size: 0.62rem;
}
.live-reserve {
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line));
border-radius: 1rem;
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
}
.live-reserve > header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
font-weight: 720;
}
.live-reserve > p {
margin: 0.65rem 0 0;
color: var(--text-muted);
font-size: 0.68rem;
}
.heinzlive-output {
min-height: 100vh;
padding: clamp(2rem, 6vw, 6rem);
background:
radial-gradient(circle at 12% 15%, rgb(103 221 232 / 12%), transparent 32rem), #0b1220;
color: #fff7e8;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.output-connection {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.65rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 2rem;
background: rgb(11 18 32 / 78%);
color: rgb(255 247 232 / 68%);
font-size: 0.68rem;
backdrop-filter: blur(10px);
}
.output-connection span {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: #67dde8;
}
.output-connection.problem {
color: #ffd0cb;
}
.output-connection.problem span {
background: #ff776d;
}
.output-content {
display: grid;
max-width: 88rem;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
align-content: center;
margin: 0 auto;
}
.output-kicker,
.output-sources > span {
color: #67dde8;
font-size: clamp(0.68rem, 1.2vw, 0.9rem);
font-weight: 760;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.output-content h1,
.output-idle h1 {
max-width: 16ch;
margin: 0.45rem 0 1.5rem;
font-size: clamp(2.8rem, 8vw, 8rem);
letter-spacing: -0.055em;
line-height: 0.94;
}
.output-copy {
max-width: 44ch;
font-family: ui-serif, Georgia, serif;
font-size: clamp(1.4rem, 2.8vw, 2.7rem);
line-height: 1.35;
}
.output-copy a,
.output-sources a {
color: #e7b957;
}
.output-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 1rem;
margin-top: 2rem;
}
.output-images img {
width: 100%;
max-height: 48vh;
object-fit: contain;
border-radius: 1.2rem;
background: rgb(255 255 255 / 5%);
}
.output-sources {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid rgb(255 255 255 / 14%);
}
.output-sources a {
font-size: 0.85rem;
}
.output-idle {
display: grid;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
place-content: center;
text-align: center;
}
.output-idle .brand-mark {
margin: 0 auto 1.5rem;
transform: scale(1.5);
}
.output-idle h1 {
max-width: 18ch;
font-size: clamp(2.4rem, 6vw, 5.5rem);
}
.output-idle p {
margin: 0;
color: rgb(255 247 232 / 62%);
font-size: clamp(0.9rem, 1.8vw, 1.3rem);
}
.quiet-panel { .quiet-panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-large); border-radius: var(--radius-large);
@@ -2002,6 +2269,544 @@ a {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.shows-page {
max-width: 78rem;
}
.show-create-panel,
.show-library,
.show-settings {
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
}
.show-create-panel h2,
.show-library h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.show-create-form {
display: grid;
grid-template-columns: minmax(12rem, 1.3fr) minmax(11rem, 0.85fr) minmax(10rem, 0.85fr) auto;
align-items: end;
gap: 0.65rem;
margin-top: 0.9rem;
}
.show-create-form label,
.show-settings-form label,
.section-edit-form label,
.add-section-form label,
.usage-edit-form label,
.usage-command-row label {
display: grid;
min-width: 0;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 700;
}
.show-create-form input,
.show-create-form select,
.show-settings-form input,
.show-settings-form select,
.show-settings-form textarea,
.section-edit-form input,
.section-edit-form select,
.add-section-form input,
.add-section-form select,
.usage-edit-form input,
.usage-edit-form textarea,
.usage-command-row select,
.source-add-form select,
.builder-filter input,
.builder-filter select,
.reserve-usage select {
width: 100%;
min-width: 0;
min-height: 2.45rem;
padding: 0.48rem 0.58rem;
border: 1px solid var(--line-strong);
border-radius: 0.65rem;
background: var(--panel-strong);
color: var(--text);
}
.template-notes {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
margin-top: 0.7rem;
color: var(--text-faint);
font-size: 0.65rem;
}
.template-notes p {
margin: 0;
}
.show-library {
margin-top: 0.8rem;
}
.show-library > header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.7rem;
}
.show-library > header > span {
padding: 0.25rem 0.5rem;
border-radius: 2rem;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.68rem;
}
.show-card-list {
display: grid;
gap: 0.5rem;
}
.show-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.show-card:hover {
border-color: var(--line-strong);
background: var(--panel-strong);
}
.show-card h3 {
margin: 0.25rem 0 0.1rem;
font-size: 0.92rem;
}
.show-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.7rem;
}
.show-state {
color: var(--text-muted);
font-size: 0.62rem;
font-weight: 750;
text-transform: uppercase;
}
.show-state.ready,
.show-state.live {
color: var(--accent-text);
}
.show-card-facts {
display: flex;
align-items: center;
gap: 1rem;
color: var(--text-muted);
font-size: 0.68rem;
}
.show-card-facts span {
display: grid;
}
.show-card-facts strong {
color: var(--text);
font-size: 0.78rem;
}
.show-settings {
margin-bottom: 0.75rem;
}
.show-settings > summary,
.usage-editor > summary,
.section-editor > summary {
cursor: pointer;
font-size: 0.74rem;
font-weight: 750;
}
.show-settings-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 0.8rem;
}
.show-settings-form .wide-field,
.usage-edit-form .wide-field {
grid-column: 1 / -1;
}
.show-settings-form small {
color: var(--text-faint);
font-weight: 500;
}
.copy-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.4rem;
}
.copy-field button,
.row-actions button {
min-width: 2.35rem;
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.row-actions button:disabled {
cursor: not-allowed;
opacity: 0.35;
}
.settings-submit {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.settings-submit span {
color: var(--text-muted);
font-size: 0.68rem;
}
.builder-filter {
display: grid;
grid-template-columns: minmax(8rem, 1fr) minmax(7rem, 0.7fr) auto;
gap: 0.35rem;
margin-bottom: 0.45rem;
}
.builder-source-list {
display: grid;
border-top: 1px solid var(--line);
}
.builder-source-card {
padding: 0.72rem 0.2rem;
border-bottom: 1px solid var(--line);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.source-drag-copy {
cursor: grab;
}
.source-drag-copy:active {
cursor: grabbing;
}
.source-drag-copy > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.source-drag-copy > div:first-child > span:last-child,
.builder-source-card p {
color: var(--text-muted);
font-size: 0.67rem;
}
.builder-source-card h3 {
margin: 0.4rem 0 0.12rem;
font-size: 0.86rem;
}
.builder-source-card p {
margin: 0;
}
.drag-hint {
display: block;
margin-top: 0.35rem;
color: var(--text-faint);
font-size: 0.61rem;
}
.builder-source-card.is-dragging {
opacity: 0.45;
transform: scale(0.985);
}
.editable-run-section.is-dragging,
.editable-usage.is-dragging,
.reserve-usage.is-dragging {
opacity: 0.38;
}
.drag-insert-preview {
border: 1px dashed color-mix(in srgb, var(--accent) 68%, var(--line-strong));
background: color-mix(in srgb, var(--accent) 10%, var(--panel-soft));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--text-muted);
animation: reveal-drop-preview 140ms ease-out;
}
.drag-insert-preview strong {
color: var(--text);
}
.section-drag-slot,
.usage-drag-slot,
.reserve-drag-slot {
display: grid;
}
.section-drag-slot {
gap: 0.35rem;
}
.usage-drag-slot,
.reserve-drag-slot {
gap: 0.42rem;
}
.section-insert-preview {
display: grid;
min-height: 4.1rem;
align-content: center;
gap: 0.15rem;
padding: 0.72rem 0.85rem;
border-radius: var(--radius-medium);
}
.section-insert-preview span,
.usage-insert-preview small,
.reserve-insert-preview small {
color: var(--accent-text);
font-size: 0.65rem;
font-weight: 720;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.usage-insert-preview {
display: grid;
min-height: 3.35rem;
align-content: center;
gap: 0.08rem;
padding: 0.55rem 0.65rem;
border-radius: 0.72rem;
}
.usage-insert-preview strong,
.reserve-insert-preview strong {
font-size: 0.78rem;
}
.reserve-insert-preview {
display: grid;
min-height: 3.35rem;
align-content: center;
gap: 0.08rem;
margin-top: 0.55rem;
padding: 0.55rem 0.65rem;
border-radius: 0.72rem;
}
@keyframes reveal-drop-preview {
from {
opacity: 0;
transform: scaleY(0.78);
}
}
.source-add-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.35rem;
margin-top: 0.55rem;
}
.editable-run-section {
position: relative;
transition:
border-color 140ms ease,
background-color 140ms ease,
opacity 140ms ease;
}
.section-drag-handle,
.usage-drag-copy {
cursor: grab;
}
.section-drag-handle:active,
.usage-drag-copy:active {
cursor: grabbing;
}
.editable-run-section > .row-actions {
position: absolute;
top: 0.68rem;
right: 4.7rem;
}
.row-actions {
display: flex;
gap: 0.25rem;
}
.row-actions form {
display: flex;
}
.row-actions button {
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0;
}
.editable-usage {
grid-template-columns: minmax(0, 1fr) auto;
transition:
opacity 140ms ease,
background-color 140ms ease;
}
.usage-editor {
grid-column: 1 / -1;
padding-top: 0.3rem;
border-top: 1px solid var(--line);
}
.usage-edit-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.55rem;
}
.usage-command-row,
.usage-command-row > form,
.section-edit-form,
.add-section-form {
display: flex;
align-items: end;
gap: 0.45rem;
flex-wrap: wrap;
}
.usage-command-row {
justify-content: space-between;
margin-top: 0.55rem;
}
.usage-command-row label,
.section-edit-form label,
.add-section-form label {
flex: 1 1 8rem;
}
.section-editor {
margin: 0.55rem 0 0 2.8rem;
}
.section-edit-form {
margin: 0.55rem 0;
}
.add-section-form {
margin-top: 0.8rem;
padding: 0.75rem;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-medium);
}
.editable-reserve {
margin-top: 0.8rem;
transition:
border-color 140ms ease,
background-color 140ms ease,
opacity 140ms ease;
}
.editable-run-section.drop-ready,
.editable-reserve.drop-ready {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.builder-source-card.is-updating,
.editable-run-section.is-updating,
.editable-reserve.is-updating,
.show-settings.is-updating,
.add-section-form.is-updating {
opacity: 0.58;
pointer-events: none;
}
.editable-reserve > header > div {
display: flex;
align-items: center;
gap: 0.65rem;
}
.editable-reserve > header > div > span {
color: var(--gold);
font-size: 1.2rem;
}
.editable-reserve > header div div {
display: grid;
}
.reserve-usage {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) auto;
align-items: end;
gap: 0.5rem;
margin-top: 0.55rem;
padding-top: 0.55rem;
border-top: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line));
}
.reserve-usage > div {
display: grid;
}
.reserve-usage small {
color: var(--text-muted);
font-size: 0.65rem;
}
.reserve-usage form {
display: flex;
align-items: end;
gap: 0.35rem;
}
.hidden-command-form {
display: none;
}
@media (max-width: 72rem) { @media (max-width: 72rem) {
.library-filters { .library-filters {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -2019,6 +2824,9 @@ a {
.source-row { .source-row {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.show-create-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 72rem) { @media (max-width: 72rem) {
@@ -2141,6 +2949,12 @@ a {
.source-row { .source-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.show-create-form,
.show-settings-form,
.usage-edit-form,
.builder-filter {
grid-template-columns: 1fr;
}
.library-filters .search-field, .library-filters .search-field,
.wide-field { .wide-field {
grid-column: auto; grid-column: auto;
@@ -2271,6 +3085,34 @@ a {
.usage-row { .usage-row {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
} }
.show-card,
.show-card-facts {
align-items: flex-start;
flex-direction: column;
}
.show-card-facts {
width: 100%;
gap: 0.4rem;
}
.show-settings-form .wide-field,
.usage-edit-form .wide-field {
grid-column: auto;
}
.copy-field,
.reserve-usage {
grid-template-columns: 1fr;
}
.copy-field button,
.reserve-usage button {
width: 100%;
}
.editable-run-section > .row-actions {
position: static;
margin: 0.5rem 0 0 2.8rem;
}
.section-editor {
margin-left: 0;
}
.live-header-actions { .live-header-actions {
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
+1
View File
@@ -7,6 +7,7 @@ declare global {
} }
interface PageData { interface PageData {
user?: AuthUser | null; user?: AuthUser | null;
presentationMode?: boolean;
} }
} }
} }
+6 -1
View File
@@ -13,11 +13,12 @@ const publicPaths = new Set([
]); ]);
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies); if (token && !event.locals.user) deleteSession(event.cookies);
if (!publicPaths.has(event.url.pathname) && !event.locals.user) { if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) {
redirect(303, hasUsers() ? '/login' : '/setup'); redirect(303, hasUsers() ? '/login' : '/setup');
} }
@@ -25,6 +26,10 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
if (presentationPath) {
response.headers.set('Cache-Control', 'no-store');
response.headers.set('Referrer-Policy', 'no-referrer');
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
} }
+19 -3
View File
@@ -47,12 +47,28 @@ describe('database setup', () => {
(SELECT count(*) FROM schema_migrations) AS migrations, (SELECT count(*) FROM schema_migrations) AS migrations,
(SELECT count(*) FROM pieces) AS pieces, (SELECT count(*) FROM pieces) AS pieces,
(SELECT count(*) FROM shows) AS shows, (SELECT count(*) FROM shows) AS shows,
(SELECT count(*) FROM piece_usages) AS usages` (SELECT count(*) FROM piece_usages) AS usages,
(SELECT count(*) FROM show_templates) AS templates,
(SELECT count(*) FROM show_sections WHERE is_reserve_bank = 1) AS reserve_banks`
) )
.get() as { migrations: number; pieces: number; shows: number; usages: number }; .get() as {
migrations: number;
pieces: number;
shows: number;
usages: number;
templates: number;
reserve_banks: number;
};
database.close(); database.close();
expect(counts).toEqual({ migrations: 3, pieces: 6, shows: 1, usages: 3 }); expect(counts).toEqual({
migrations: 5,
pieces: 6,
shows: 2,
usages: 5,
templates: 2,
reserve_banks: 2
});
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite')); expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
}); });
+137
View File
@@ -0,0 +1,137 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
getLiveView,
getPresentationMedia,
getPresentationView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
skipCurrent,
startOrResumeLive
} from './live.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-live-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('live mode', () => {
it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started);
expect(initial.current).toBeNull();
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
revision: 1,
duplicate: false
});
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
revision: 1,
duplicate: true
});
let live = getLiveView('show-october', new Date('2026-10-22T18:05:00.000Z'));
expect(live.current?.id).toBe('usage-console');
expect(live.current?.actualDurationSeconds).toBe(300);
expect(() =>
moveOpenUsage('show-october', 'usage-effects', live.show.revision, {
sectionId: 'section-stories',
position: 0
})
).toThrow('LIVE_ORDER_LOCKED');
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:06:00.000Z'));
expect(live.current?.id).toBe('usage-effects');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')
).toMatchObject({ status: 'done', actualDurationSeconds: 300 });
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get('usage-console')
).toEqual({ count: 1 });
expect(() => skipCurrent('show-october', 'stale-command', 1)).toThrow('CONFLICT');
skipCurrent('show-october', 'skip-effects', 2, new Date('2026-10-22T18:06:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:07:00.000Z'));
expect(live.current).toBeNull();
expect(live.completedCount).toBe(2);
goBack('show-october', 'back-effects', 3, new Date('2026-10-22T18:07:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:08:00.000Z'));
expect(live.current).toMatchObject({ id: 'usage-effects', status: 'current' });
expect(live.current?.actualDurationSeconds).toBe(120);
});
it('changes presentation only explicitly and exposes no private fields', async () => {
const uploads = process.env.SENDEWERK_UPLOAD_DIR!;
mkdirSync(uploads, { recursive: true });
writeFileSync(path.join(uploads, 'presentation.png'), Buffer.from('test-image'));
getDatabase()
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
'presentation-image',
'piece-console',
'presentation.png',
'private-original-name.png',
'image/png',
10,
'Konsole auf dem Tisch',
0,
'2026-10-22T18:00:00.000Z'
);
const token = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
startOrResumeLive('show-october', 'start-output', 0);
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
presentUsage('show-october', 'usage-console', 'present-console', 1);
const output = getPresentationView(token)!;
expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
});
const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
expect(serialized).not.toContain('Herstellerangaben');
expect(serialized).not.toContain('Moderationsstichpunkte');
expect(serialized).not.toContain('private-original-name');
expect(serialized).not.toContain('presentation.png');
const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image');
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toBeNull();
expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
});
});
+563
View File
@@ -0,0 +1,563 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import {
getShow,
movePieceUsage,
type MovePieceUsageInput,
type PieceUsage,
type ShowSection
} from './shows.js';
type LiveStateRow = {
show_id: string;
revision: number;
current_usage_id: string | null;
started_at_utc: string | null;
ended_at_utc: string | null;
presentation_usage_id: string | null;
presentation_revision: number;
updated_at_utc: string;
};
type OrderedUsageRow = {
id: string;
section_id: string;
section_name: string;
section_position: number;
position: number;
status: PieceUsage['status'];
};
export type LiveUsage = PieceUsage & {
sectionId: string;
sectionName: string;
actualDurationSeconds: number;
introHtml: string;
researchHtml: string;
moderationHtml: string;
transitionHtml: string;
};
export type LiveSection = Omit<ShowSection, 'usages'> & { usages: LiveUsage[] };
export type LiveView = {
show: {
id: string;
title: string;
status: string;
revision: number;
totalDurationSeconds: number;
};
liveRevision: number;
startedAtUtc: string | null;
updatedAtUtc: string;
presentationUsageId: string | null;
presentationRevision: number;
sections: LiveSection[];
reserve: LiveUsage[];
current: LiveUsage | null;
next: LiveUsage | null;
completedCount: number;
totalCount: number;
};
export type PresentationView = {
revision: number;
content: null | {
title: string;
html: string;
sources: { label: string; url: string; timecodeSeconds: number | null }[];
images: { id: string; altText: string }[];
};
};
function hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
function nowIso(now?: Date) {
return (now ?? new Date()).toISOString();
}
function requireCommandId(commandId: string) {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
}
function ensureLiveState(database: Database.Database, showId: string, now: string): LiveStateRow {
const show = database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId);
if (!show) throw new Error('NOT_FOUND');
database
.prepare(
`INSERT OR IGNORE INTO show_live_states (show_id, updated_at_utc)
VALUES (?, ?)`
)
.run(showId, now);
return database
.prepare('SELECT * FROM show_live_states WHERE show_id = ?')
.get(showId) as LiveStateRow;
}
function orderedUsages(database: Database.Database, showId: string): OrderedUsageRow[] {
return database
.prepare(
`SELECT u.id, s.id AS section_id, s.name AS section_name,
s.position AS section_position, u.position, u.status
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0
ORDER BY s.position, u.position`
)
.all(showId) as OrderedUsageRow[];
}
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database
.prepare(
`SELECT s.is_reserve_bank FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
}
function closeActiveRun(database: Database.Database, showId: string, now: string) {
database
.prepare(
`UPDATE live_usage_runs SET ended_at_utc = ?
WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, showId);
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
usageBelongsToShow(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET status = 'open'
WHERE status = 'current' AND id != ? AND id IN (
SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ?
)`
)
.run(usageId, showId);
database.prepare(`UPDATE piece_usages SET status = 'current' WHERE id = ?`).run(usageId);
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, usageId, now);
database
.prepare(
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
)
.run(usageId, showId);
}
function executeCommand(
showId: string,
commandId: string,
expectedRevision: number,
action: string,
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
nowValue?: Date
) {
requireCommandId(commandId);
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare('SELECT result_revision FROM live_commands WHERE show_id = ? AND command_id = ?')
.get(showId, commandId) as { result_revision: number } | undefined;
if (duplicate) return { revision: duplicate.result_revision, duplicate: true };
const now = nowIso(nowValue);
const state = ensureLiveState(database, showId, now);
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string;
};
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now);
const updated = database
.prepare(
`UPDATE show_live_states SET revision = revision + 1, updated_at_utc = ?
WHERE show_id = ? AND revision = ?`
)
.run(now, showId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const revision = expectedRevision + 1;
database
.prepare(
`INSERT INTO live_commands (show_id, command_id, action, result_revision, created_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(showId, commandId, action, revision, now);
return { revision, duplicate: false };
})();
}
function actualDurations(database: Database.Database, showId: string, now: string) {
const rows = database
.prepare(
`SELECT usage_id, started_at_utc, ended_at_utc
FROM live_usage_runs WHERE show_id = ?`
)
.all(showId) as { usage_id: string; started_at_utc: string; ended_at_utc: string | null }[];
const durations = new Map<string, number>();
for (const row of rows) {
const seconds = Math.max(
0,
Math.floor(
(new Date(row.ended_at_utc ?? now).getTime() - new Date(row.started_at_utc).getTime()) /
1000
)
);
durations.set(row.usage_id, (durations.get(row.usage_id) ?? 0) + seconds);
}
return durations;
}
function liveUsage(
usage: PieceUsage,
sectionId: string,
sectionName: string,
actualSeconds: number
) {
return {
...usage,
sectionId,
sectionName,
actualDurationSeconds: actualSeconds,
introHtml: renderMarkdown(usage.intro),
researchHtml: renderMarkdown(usage.research),
moderationHtml: renderMarkdown(usage.moderation),
transitionHtml: renderMarkdown(usage.transition)
};
}
export function getLiveView(showId: string, nowValue?: Date): LiveView {
const database = getDatabase();
const now = nowIso(nowValue);
const state = ensureLiveState(database, showId, now);
const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND');
const durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({
...section,
usages: section.usages.map((usage) =>
liveUsage(usage, section.id, section.name, durations.get(usage.id) ?? 0)
)
}));
const reserve = show.reserve.map((usage) =>
liveUsage(usage, '', 'Reservebank', durations.get(usage.id) ?? 0)
);
const usages = sections.flatMap((section) => section.usages);
const current = usages.find((usage) => usage.id === state.current_usage_id) ?? null;
const currentIndex = current ? usages.findIndex((usage) => usage.id === current.id) : -1;
const next =
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
return {
show: {
id: show.id,
title: show.title,
status: show.status,
revision: show.revision,
totalDurationSeconds: show.totalDurationSeconds
},
liveRevision: state.revision,
startedAtUtc: state.started_at_utc,
updatedAtUtc: state.updated_at_utc,
presentationUsageId: state.presentation_usage_id,
presentationRevision: state.presentation_revision,
sections,
reserve,
current,
next,
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
.length,
totalCount: usages.length
};
}
export function startOrResumeLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'start',
(database, state, now) => {
database
.prepare(
`UPDATE shows SET status = 'live', updated_at_utc = ?
WHERE id = ? AND status != 'completed'`
)
.run(now, showId);
database
.prepare(
`UPDATE show_live_states SET started_at_utc = COALESCE(started_at_utc, ?), ended_at_utc = NULL
WHERE show_id = ?`
)
.run(now, showId);
const activeRun = database
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId);
if (state.current_usage_id && activeRun) return;
const current = state.current_usage_id
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
: undefined;
const target =
current ?? orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now);
},
nowValue
);
}
function advanceCurrent(
showId: string,
commandId: string,
expectedRevision: number,
status: 'done' | 'skipped',
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
status,
(database, state, now) => {
if (!state.current_usage_id) throw new Error('NO_CURRENT');
const ordered = orderedUsages(database, showId);
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
if (currentIndex < 0) throw new Error('NOT_FOUND');
closeActiveRun(database, showId, now);
database
.prepare('UPDATE piece_usages SET status = ? WHERE id = ?')
.run(status, state.current_usage_id);
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
if (next) activateUsage(database, showId, next.id, now);
else
database
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
.run(showId);
},
nowValue
);
}
export function completeCurrent(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return advanceCurrent(showId, commandId, expectedRevision, 'done', nowValue);
}
export function skipCurrent(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return advanceCurrent(showId, commandId, expectedRevision, 'skipped', nowValue);
}
export function goBack(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'back',
(database, state, now) => {
const ordered = orderedUsages(database, showId);
const currentIndex = state.current_usage_id
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
: ordered.length;
const previous = ordered
.slice(0, Math.max(0, currentIndex))
.reverse()
.find((usage) => usage.status === 'done' || usage.status === 'skipped');
if (!previous) throw new Error('NO_PREVIOUS');
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare(`UPDATE piece_usages SET status = 'open' WHERE id = ?`)
.run(state.current_usage_id);
}
activateUsage(database, showId, previous.id, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
usageId ? 'present' : 'clear-presentation',
(database, _state, now) => {
if (usageId) usageBelongsToShow(database, showId, usageId);
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = ?,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ?`
)
.run(usageId, now, showId);
},
nowValue
);
}
export function moveOpenUsage(
showId: string,
usageId: string,
expectedShowRevision: number,
input: MovePieceUsageInput
) {
const database = getDatabase();
const usage = database
.prepare(
`SELECT u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { status: PieceUsage['status'] } | undefined;
if (!usage) throw new Error('NOT_FOUND');
if (usage.status !== 'open') throw new Error('LIVE_ORDER_LOCKED');
if (input.sectionId) {
const target = database
.prepare(
`SELECT u.id, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY u.position`
)
.all(input.sectionId, showId) as { id: string; status: PieceUsage['status'] }[];
if (!target.length) {
const section = database
.prepare('SELECT 1 FROM show_sections WHERE id = ? AND show_id = ? AND is_reserve_bank = 0')
.get(input.sectionId, showId);
if (!section) throw new Error('NOT_FOUND');
}
const withoutMoved = target.filter((item) => item.id !== usageId);
const firstOpenPosition = withoutMoved.findIndex((item) => item.status === 'open');
const minimum = firstOpenPosition < 0 ? withoutMoved.length : firstOpenPosition;
if (input.position < minimum) throw new Error('LIVE_ORDER_LOCKED');
}
return movePieceUsage(showId, usageId, expectedShowRevision, input);
}
export function issuePresentationAccess(showId: string) {
const database = getDatabase();
const now = nowIso();
ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url');
database.transaction(() => {
database
.prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
)
.run(now, showId);
database
.prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, hashToken(token), now);
})();
return token;
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const row = database
.prepare(
`SELECT show_id FROM presentation_access_tokens
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(hashToken(token)) as { show_id: string } | undefined;
return row?.show_id ?? null;
}
export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id
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(state.presentation_usage_id, showId) as
| { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null }
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources = usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images = usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
title: usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
sources,
images
}
};
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const allowed = database
.prepare(
`SELECT 1 FROM show_live_states l
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
}
+49 -94
View File
@@ -1,4 +1,5 @@
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { getShow as getShowById, listShows } from './shows.js';
export type PreviewPiece = { export type PreviewPiece = {
id: string; id: string;
@@ -104,109 +105,63 @@ export function getPieces(): PreviewPiece[] {
})); }));
} }
type ShowRow = { export function getShow(showId?: string): PreviewShow {
id: string; const latest = showId ? null : listShows().find((show) => show.startsAtUtc !== null);
title: string; const show = getShowById(showId ?? latest?.id ?? '');
starts_at_utc: string;
twitch_title: string;
announcement_text: string;
status: string;
};
type SectionRow = {
id: string;
name: string;
category: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
};
type UsageRow = {
id: string;
show_section_id: string;
title_snapshot: string;
duration_seconds: number;
status: PreviewUsage['status'];
intro_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
is_reserve: number;
};
export function getShow(): PreviewShow {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status
FROM shows ORDER BY starts_at_utc DESC LIMIT 1`
)
.get() as ShowRow | undefined;
if (!show) throw new Error('No preview show found. Run npm run db:seed.'); if (!show) throw new Error('No preview show found. Run npm run db:seed.');
const sections: PreviewSection[] = show.sections.map((section) => ({
const sectionRows = database id: section.id,
.prepare( name: section.name,
`SELECT s.id, s.name, c.name AS category, c.color AS category_color, category: section.category ?? 'Offen',
s.explicit_duration_seconds categoryColor: section.categoryColor ?? '#8b8f98',
FROM show_sections s durationSeconds: section.durationSeconds,
LEFT JOIN section_categories c ON c.id = s.category_id usages: section.usages.map((usage) => ({
WHERE s.show_id = ? ORDER BY s.position` id: usage.id,
) title: usage.title,
.all(show.id) as SectionRow[]; durationSeconds: usage.durationSeconds,
const usageRows = database status: usage.status,
.prepare( intro: usage.intro,
`SELECT id, show_section_id, title_snapshot, duration_seconds, status, moderation: usage.moderation,
intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve transition: usage.transition,
FROM piece_usages WHERE show_section_id IN ( isReserve: false
SELECT id FROM show_sections WHERE show_id = ? }))
) ORDER BY position` }));
) const reserveHost = sections[Math.min(1, sections.length - 1)];
.all(show.id) as UsageRow[]; if (reserveHost) {
reserveHost.usages.push(
const sections = sectionRows.map((section) => { ...show.reserve.map((usage) => ({
const usages = usageRows
.filter((usage) => usage.show_section_id === section.id)
.map((usage) => ({
id: usage.id, id: usage.id,
title: usage.title_snapshot, title: usage.title,
durationSeconds: usage.duration_seconds, durationSeconds: usage.durationSeconds,
status: usage.status, status: usage.status,
intro: usage.intro_snapshot, intro: usage.intro,
moderation: usage.moderation_snapshot, moderation: usage.moderation,
transition: usage.transition_snapshot, transition: usage.transition,
isReserve: usage.is_reserve === 1 isReserve: true
})); }))
const plannedUsages = usages.filter((usage) => !usage.isReserve); );
return { }
id: section.id,
name: section.name,
category: section.category ?? 'Offen',
categoryColor: section.category_color ?? '#8b8f98',
durationSeconds:
section.explicit_duration_seconds ??
plannedUsages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
return { return {
id: show.id, id: show.id,
title: show.title, title: show.title,
startsAtUtc: show.starts_at_utc, startsAtUtc: show.startsAtUtc ?? '',
startsAtBerlin: new Intl.DateTimeFormat('de-DE', { startsAtBerlin: show.startsAtUtc
timeZone: 'Europe/Berlin', ? new Intl.DateTimeFormat('de-DE', {
weekday: 'short', timeZone: 'Europe/Berlin',
day: '2-digit', weekday: 'short',
month: '2-digit', day: '2-digit',
year: 'numeric', month: '2-digit',
hour: '2-digit', year: 'numeric',
minute: '2-digit' hour: '2-digit',
}).format(new Date(show.starts_at_utc)), minute: '2-digit'
twitchTitle: show.twitch_title, }).format(new Date(show.startsAtUtc))
announcement: show.announcement_text, : 'Termin offen',
twitchTitle: show.twitchTitle,
announcement: show.announcementText,
status: show.status, status: show.status,
sections, sections,
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) totalDurationSeconds: show.totalDurationSeconds
}; };
} }
+272
View File
@@ -0,0 +1,272 @@
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 { getPiece } from './content.js';
import { closeDatabase, getDatabase } from './database.js';
import {
addPieceUsage,
addShowSection,
createShow,
getShow,
listShowTemplates,
movePieceUsage,
moveShowSection,
removePieceUsage,
removeShowSection,
updatePieceUsage,
updateShowSection,
type PieceUsage
} from './shows.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shows-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 });
});
function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
const edited = { ...usage, ...overrides };
return {
title: edited.title,
intro: edited.intro,
research: edited.research,
moderation: edited.moderation,
transition: edited.transition,
presentation: edited.presentation,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds,
status: edited.status
};
}
describe('show builder data layer', () => {
it('creates blank and templated shows with independent sections and reserve banks', () => {
const templatesBefore = listShowTemplates();
expect(
templatesBefore.find((template) => template.id === 'template-default')?.sections
).toHaveLength(5);
expect(templatesBefore.find((template) => template.id === 'template-games')?.name).toBe(
'Spieleabend'
);
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
expect(getShow(blank.id)).toMatchObject({
title: 'Neue Sendung',
startsAtUtc: null,
sections: [],
reserve: []
});
const reserveCount = getDatabase()
.prepare(
'SELECT count(*) AS count FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1'
)
.get(blank.id) as { count: number };
expect(reserveCount.count).toBe(1);
const templated = createShow({
title: 'Vorlagenkopie',
startsAtUtc: '2027-01-08T19:00:00Z',
templateId: 'template-default'
});
const copy = getShow(templated.id)!;
expect(copy.sections.map((section) => section.name)).toEqual([
'Start',
'JC / Reaction',
'Spiel 1',
'JC',
'Spiel 2'
]);
updateShowSection(templated.id, copy.sections[0].id, copy.revision, {
name: 'Eigener Start',
categoryId: 'cat-jc',
explicitDurationSeconds: 420
});
expect(listShowTemplates()).toEqual(templatesBefore);
});
it('snapshots a ready piece repeatedly and keeps usage edits independent from its original', () => {
const created = createShow({
title: 'Doppelte Verwendung',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Fundstücke',
categoryId: 'cat-reaction',
explicitDurationSeconds: null
});
const first = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const second = addPieceUsage(created.id, first.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const usages = getShow(created.id)!.sections[0].usages;
const firstSaved = updatePieceUsage(
created.id,
first.id,
second.revision,
editedUsage(usages[0], {
title: 'Konsole als Auftakt',
durationSeconds: 300,
ownNotes: 'Kurbel zuerst zeigen',
research: 'Eigene Recherchekopie'
})
);
updatePieceUsage(
created.id,
second.id,
firstSaved.revision,
editedUsage(usages[1], { title: 'Konsole als Rückblick', durationSeconds: 900 })
);
const show = getShow(created.id)!;
expect(show.sections[0].usages).toMatchObject([
{
title: 'Konsole als Auftakt',
durationSeconds: 300,
ownNotes: 'Kurbel zuerst zeigen',
research: 'Eigene Recherchekopie'
},
{ title: 'Konsole als Rückblick', durationSeconds: 900 }
]);
expect(getPiece('piece-console')).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
durationSeconds: 480,
researchText:
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten'
});
});
it('persists normalized section and usage reordering, moving and removal', () => {
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
const firstSection = addShowSection(created.id, created.revision, {
name: 'Erster Block',
categoryId: null,
explicitDurationSeconds: null
});
const secondSection = addShowSection(created.id, firstSection.revision, {
name: 'Zweiter Block',
categoryId: null,
explicitDurationSeconds: null
});
const firstUsage = addPieceUsage(created.id, secondSection.revision, {
pieceId: 'piece-console',
sectionId: firstSection.id
});
const secondUsage = addPieceUsage(created.id, firstUsage.revision, {
pieceId: 'piece-effects',
sectionId: firstSection.id
});
const thirdUsage = addPieceUsage(created.id, secondUsage.revision, {
pieceId: 'piece-pixels',
sectionId: firstSection.id
});
const reordered = movePieceUsage(created.id, thirdUsage.id, thirdUsage.revision, {
sectionId: firstSection.id,
position: 0
});
const reserved = movePieceUsage(created.id, firstUsage.id, reordered.revision, {
sectionId: null,
position: 0
});
const plannedAgain = movePieceUsage(created.id, firstUsage.id, reserved.revision, {
sectionId: secondSection.id,
position: 0
});
const removedUsage = removePieceUsage(created.id, secondUsage.id, plannedAgain.revision);
const movedSection = moveShowSection(created.id, secondSection.id, 0, removedUsage.revision);
removeShowSection(created.id, firstSection.id, movedSection.revision);
const show = getShow(created.id)!;
expect(show.sections.map((section) => [section.name, section.position])).toEqual([
['Zweiter Block', 0]
]);
expect(show.sections[0].usages.map((usage) => [usage.id, usage.position])).toEqual([
[firstUsage.id, 0]
]);
expect(show.reserve).toEqual([]);
expect(getDatabase().pragma('foreign_key_check')).toEqual([]);
});
it('uses explicit section durations once and excludes the show-wide reserve', () => {
const created = createShow({ title: 'Dauer', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Fester Block',
categoryId: null,
explicitDurationSeconds: 600
});
const planned = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-effects',
sectionId: section.id
});
addPieceUsage(created.id, planned.revision, {
pieceId: 'piece-pixels',
sectionId: null
});
const explicit = getShow(created.id)!;
expect(explicit.sections[0].durationSeconds).toBe(600);
expect(explicit.totalDurationSeconds).toBe(600);
expect(explicit.reserve[0].durationSeconds).toBe(540);
updateShowSection(created.id, section.id, explicit.revision, {
name: 'Summierter Block',
categoryId: null,
explicitDurationSeconds: null
});
expect(getShow(created.id)).toMatchObject({
totalDurationSeconds: 720,
sections: [{ durationSeconds: 720 }]
});
});
it('rolls stale and invalid aggregate mutations back and rejects completed shows', () => {
const created = createShow({ title: 'Konflikt', startsAtUtc: null, templateId: null });
const saved = addShowSection(created.id, created.revision, {
name: 'Gültig',
categoryId: null,
explicitDurationSeconds: null
});
expect(() =>
addShowSection(created.id, created.revision, {
name: 'Veraltet',
categoryId: null,
explicitDurationSeconds: null
})
).toThrow('CONFLICT');
expect(getShow(created.id)).toMatchObject({
revision: saved.revision,
sections: [{ name: 'Gültig' }]
});
expect(() => removeShowSection(created.id, 'missing', saved.revision)).toThrow('NOT_FOUND');
expect(getShow(created.id)?.revision).toBe(saved.revision);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(created.id);
expect(() =>
addPieceUsage(created.id, saved.revision, {
pieceId: 'piece-console',
sectionId: saved.id
})
).toThrow('SHOW_COMPLETED');
expect(getShow(created.id)).toMatchObject({
revision: saved.revision,
sections: [{ usages: [] }]
});
});
});
+796
View File
@@ -0,0 +1,796 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getDatabase } from './database.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
export type ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type ShowListItem = {
id: string;
title: string;
startsAtUtc: string | null;
status: ShowStatus;
revision: number;
totalDurationSeconds: number;
sectionCount: number;
};
export type ShowTemplateSection = {
id: string;
name: string;
categoryId: string | null;
position: number;
explicitDurationSeconds: number | null;
};
export type ShowTemplate = {
id: string;
name: string;
description: string;
sections: ShowTemplateSection[];
};
export type PieceUsage = {
id: string;
originalPieceId: string | null;
position: number;
status: UsageStatus;
durationSeconds: number;
title: string;
intro: string;
research: string;
moderation: string;
transition: string;
presentation: string;
ownNotes: string;
};
export type ShowSection = {
id: string;
name: string;
categoryId: string | null;
category: string | null;
categoryColor: string | null;
position: number;
explicitDurationSeconds: number | null;
durationSeconds: number;
usages: PieceUsage[];
};
export type Show = {
id: string;
title: string;
startsAtUtc: string | null;
twitchTitle: string;
announcementText: string;
status: ShowStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
sections: ShowSection[];
reserve: PieceUsage[];
totalDurationSeconds: number;
};
export type CreateShowInput = {
title: string | null;
startsAtUtc: string | null;
templateId: string | null;
};
export type ShowMetadataInput = {
title: string;
startsAtUtc: string | null;
twitchTitle: string;
announcementText: string;
status: EditableShowStatus;
};
export type ShowSectionInput = {
name: string;
categoryId: string | null;
explicitDurationSeconds: number | null;
position?: number;
};
export type PieceUsageInput = {
pieceId: string;
sectionId: string | null;
position?: number;
};
export type PieceUsageUpdate = {
title: string;
intro: string;
research: string;
moderation: string;
transition: string;
presentation: string;
ownNotes: string;
durationSeconds: number;
status: UsageStatus;
};
export type MovePieceUsageInput = {
sectionId: string | null;
position: number;
};
export type MutationResult = { revision: number; updatedAtUtc: string };
export type CreatedMutationResult = MutationResult & { id: string };
type ShowRow = {
id: string;
title: string;
starts_at_utc: string | null;
twitch_title: string;
announcement_text: string;
status: ShowStatus;
revision: number;
created_at_utc: string;
updated_at_utc: string;
};
type SectionRow = {
id: string;
name: string;
category_id: string | null;
category: string | null;
category_color: string | null;
position: number;
explicit_duration_seconds: number | null;
is_reserve_bank: number;
};
type UsageRow = {
id: string;
show_section_id: string;
original_piece_id: string | null;
position: number;
status: UsageStatus;
duration_seconds: number;
title_snapshot: string;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
presentation_snapshot: string;
own_notes: string;
};
function cleanText(value: unknown, maximum: number, field: string, required = false): string {
const text = String(value ?? '').trim();
if (text.length > maximum || (required && !text)) {
throw new Error(`INVALID_${field.toUpperCase()}`);
}
return text;
}
function validDuration(value: number | null): number | null {
if (value === null) return null;
if (!Number.isInteger(value) || value < 0 || value > 86_400) {
throw new Error('INVALID_DURATION');
}
return value;
}
function validPosition(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum);
}
function validTimestamp(value: string | null): string | null {
if (value === null) return null;
if (!value.endsWith('Z')) throw new Error('INVALID_TIMESTAMP');
const date = new Date(value);
if (Number.isNaN(date.valueOf())) throw new Error('INVALID_TIMESTAMP');
return date.toISOString();
}
function requireRevision(expectedRevision: number): void {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
}
function requireCategory(database: Database.Database, categoryId: string | null): void {
if (!categoryId) return;
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(categoryId)) {
throw new Error('INVALID_CATEGORY');
}
}
function bumpShow(
database: Database.Database,
showId: string,
expectedRevision: number
): MutationResult {
requireRevision(expectedRevision);
const updatedAtUtc = new Date().toISOString();
const result = database
.prepare(
`UPDATE shows SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'completed'`
)
.run(updatedAtUtc, showId, expectedRevision);
if (result.changes !== 1) {
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as
{ status: ShowStatus } | undefined;
if (!show) throw new Error('NOT_FOUND');
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
throw new Error('CONFLICT');
}
return { revision: expectedRevision + 1, updatedAtUtc };
}
function reserveSectionId(database: Database.Database, showId: string): string {
const row = database
.prepare('SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1')
.get(showId) as { id: string } | undefined;
if (!row) throw new Error('INVALID_RESERVE_BANK');
return row.id;
}
function sectionBelongsToShow(
database: Database.Database,
showId: string,
sectionId: string,
allowReserve = false
): void {
const row = database
.prepare('SELECT is_reserve_bank FROM show_sections WHERE id = ? AND show_id = ?')
.get(sectionId, showId) as { is_reserve_bank: number } | undefined;
if (!row || (!allowReserve && row.is_reserve_bank === 1)) throw new Error('NOT_FOUND');
}
function usageSection(
database: Database.Database,
showId: string,
usageId: string
): { sectionId: string } {
const row = database
.prepare(
`SELECT u.show_section_id AS sectionId FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { sectionId: string } | undefined;
if (!row) throw new Error('NOT_FOUND');
return row;
}
function normalizePositions(
database: Database.Database,
table: 'show_sections' | 'piece_usages',
ids: string[]
): void {
if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', ');
const maximum = database
.prepare(
`SELECT COALESCE(max(position), -1) AS maximum FROM ${table} WHERE id IN (${placeholders})`
)
.get(...ids) as { maximum: number };
const offset = maximum.maximum + ids.length + 1;
database
.prepare(`UPDATE ${table} SET position = position + ? WHERE id IN (${placeholders})`)
.run(offset, ...ids);
const update = database.prepare(`UPDATE ${table} SET position = ? WHERE id = ?`);
ids.forEach((id, position) => update.run(position, id));
}
function usageFromRow(row: UsageRow): PieceUsage {
return {
id: row.id,
originalPieceId: row.original_piece_id,
position: row.position,
status: row.status,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
intro: row.intro_snapshot,
research: row.research_snapshot,
moderation: row.moderation_snapshot,
transition: row.transition_snapshot,
presentation: row.presentation_snapshot,
ownNotes: row.own_notes
};
}
export function listShows(): ShowListItem[] {
return (
getDatabase()
.prepare(
`SELECT sh.id, sh.title, sh.starts_at_utc, sh.status, sh.revision,
count(DISTINCT CASE WHEN s.is_reserve_bank = 0 THEN s.id END) AS section_count,
COALESCE(sum(CASE WHEN s.is_reserve_bank = 0 THEN
COALESCE(s.explicit_duration_seconds,
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
ELSE 0 END), 0) AS total_duration_seconds
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
GROUP BY sh.id
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
)
.all() as {
id: string;
title: string;
starts_at_utc: string | null;
status: ShowStatus;
revision: number;
section_count: number;
total_duration_seconds: number;
}[]
).map((row) => ({
id: row.id,
title: row.title,
startsAtUtc: row.starts_at_utc,
status: row.status,
revision: row.revision,
totalDurationSeconds: row.total_duration_seconds,
sectionCount: row.section_count
}));
}
export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase();
const templates = database
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
.all() as Omit<ShowTemplate, 'sections'>[];
const sectionStatement = database.prepare(
`SELECT id, name, category_id, position, explicit_duration_seconds
FROM show_template_sections WHERE template_id = ? ORDER BY position`
);
return templates.map((template) => ({
...template,
sections: (
sectionStatement.all(template.id) as {
id: string;
name: string;
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
}[]
).map((section) => ({
id: section.id,
name: section.name,
categoryId: section.category_id,
position: section.position,
explicitDurationSeconds: section.explicit_duration_seconds
}))
}));
}
export function getShow(id: string): Show | null {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc FROM shows WHERE id = ?`
)
.get(id) as ShowRow | undefined;
if (!show) return null;
const sectionRows = database
.prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.position, s.explicit_duration_seconds, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
.all(id) as SectionRow[];
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? ORDER BY s.position, u.position`
)
.all(id) as UsageRow[];
const usagesBySection = new Map<string, PieceUsage[]>();
for (const row of usageRows) {
const usages = usagesBySection.get(row.show_section_id) ?? [];
usages.push(usageFromRow(row));
usagesBySection.set(row.show_section_id, usages);
}
const sections = sectionRows
.filter((row) => row.is_reserve_bank === 0)
.map((row) => {
const usages = usagesBySection.get(row.id) ?? [];
return {
id: row.id,
name: row.name,
categoryId: row.category_id,
category: row.category,
categoryColor: row.category_color,
position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds,
durationSeconds:
row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
const reserveRow = sectionRows.find((row) => row.is_reserve_bank === 1);
return {
id: show.id,
title: show.title,
startsAtUtc: show.starts_at_utc,
twitchTitle: show.twitch_title,
announcementText: show.announcement_text,
status: show.status,
revision: show.revision,
createdAtUtc: show.created_at_utc,
updatedAtUtc: show.updated_at_utc,
sections,
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
};
}
export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase();
const id = randomUUID();
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString();
const create = database.transaction(() => {
if (
input.templateId &&
!database.prepare('SELECT 1 FROM show_templates WHERE id = ?').get(input.templateId)
) {
throw new Error('INVALID_TEMPLATE');
}
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, ?, ?)`
)
.run(id, title, startsAtUtc, now, now);
const templateSections = input.templateId
? (database
.prepare(
`SELECT name, category_id, position, explicit_duration_seconds
FROM show_template_sections WHERE template_id = ? ORDER BY position`
)
.all(input.templateId) as {
name: string;
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
}[])
: [];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)`
);
for (const section of templateSections) {
insertSection.run(
randomUUID(),
id,
section.name,
section.category_id,
section.position,
section.explicit_duration_seconds,
0
);
}
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
});
create();
return { id, revision: 1, updatedAtUtc: now };
}
export function saveShowMetadata(
showId: string,
expectedRevision: number,
input: ShowMetadataInput
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const twitchTitle = cleanText(input.twitchTitle, 200, 'twitch_title');
const announcementText = cleanText(input.announcementText, 20_000, 'announcement');
if (!['draft', 'ready', 'live'].includes(input.status)) throw new Error('INVALID_STATUS');
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
database
.prepare(
`UPDATE shows SET title = ?, starts_at_utc = ?, twitch_title = ?,
announcement_text = ?, status = ? WHERE id = ?`
)
.run(title, startsAtUtc, twitchTitle, announcementText, input.status, showId);
return result;
})();
}
export function addShowSection(
showId: string,
expectedRevision: number,
input: ShowSectionInput
): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const visibleIds = (
database
.prepare(
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
)
.all(showId) as { id: string }[]
).map((row) => row.id);
const position = validPosition(input.position ?? visibleIds.length, visibleIds.length);
const id = randomUUID();
const allIds = (
database
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
.all(showId) as {
id: string;
}[]
).map((row) => row.id);
const temporaryPosition = allIds.length;
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)`
)
.run(id, showId, name, input.categoryId, temporaryPosition, duration);
visibleIds.splice(position, 0, id);
const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
return { ...result, id };
})();
}
export function updateShowSection(
showId: string,
sectionId: string,
expectedRevision: number,
input: Omit<ShowSectionInput, 'position'>
): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
database
.prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?
WHERE id = ?`
)
.run(name, input.categoryId, duration, sectionId);
return result;
})();
}
export function removeShowSection(
showId: string,
sectionId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
const ids = (
database
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
.all(showId) as {
id: string;
}[]
).map((row) => row.id);
normalizePositions(database, 'show_sections', ids);
return result;
})();
}
export function moveShowSection(
showId: string,
sectionId: string,
targetPosition: number,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
const ids = (
database
.prepare(
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
)
.all(showId) as { id: string }[]
).map((row) => row.id);
const current = ids.indexOf(sectionId);
ids.splice(current, 1);
ids.splice(validPosition(targetPosition, ids.length), 0, sectionId);
ids.push(reserveSectionId(database, showId));
normalizePositions(database, 'show_sections', ids);
return result;
})();
}
export function addPieceUsage(
showId: string,
expectedRevision: number,
input: PieceUsageInput
): CreatedMutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const sectionId = input.sectionId ?? reserveSectionId(database, showId);
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database
.prepare(
`SELECT title, duration_seconds, intro, research_text, moderation_notes,
transition_text, presentation_text, status FROM pieces WHERE id = ?`
)
.get(input.pieceId) as
| {
title: string;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
status: string;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(sectionId) as { id: string }[]
).map((row) => row.id);
const position = validPosition(input.position ?? ids.length, ids.length);
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
sectionId,
input.pieceId,
ids.length,
piece.duration_seconds,
piece.title,
piece.intro,
piece.research_text,
piece.moderation_notes,
piece.transition_text,
piece.presentation_text,
input.sectionId === null ? 1 : 0
);
ids.splice(position, 0, id);
normalizePositions(database, 'piece_usages', ids);
return { ...result, id };
})();
}
export function updatePieceUsage(
showId: string,
usageId: string,
expectedRevision: number,
input: PieceUsageUpdate
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const duration = validDuration(input.durationSeconds)!;
if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS');
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
moderation: cleanText(input.moderation, 50_000, 'moderation'),
transition: cleanText(input.transition, 50_000, 'transition'),
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
};
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?`
)
.run(
title,
values.intro,
values.research,
values.moderation,
values.transition,
values.presentation,
values.ownNotes,
duration,
input.status,
usageId
);
return result;
})();
}
export function removePieceUsage(
showId: string,
usageId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(source.sectionId) as { id: string }[]
).map((row) => row.id);
normalizePositions(database, 'piece_usages', ids);
return result;
})();
}
export function movePieceUsage(
showId: string,
usageId: string,
expectedRevision: number,
input: MovePieceUsageInput
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
const targetSectionId = input.sectionId ?? reserveSectionId(database, showId);
sectionBelongsToShow(database, showId, targetSectionId, input.sectionId === null);
const sourceIds = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(source.sectionId) as { id: string }[]
).map((row) => row.id);
if (source.sectionId === targetSectionId) {
sourceIds.splice(sourceIds.indexOf(usageId), 1);
sourceIds.splice(validPosition(input.position, sourceIds.length), 0, usageId);
normalizePositions(database, 'piece_usages', sourceIds);
} else {
const targetIds = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(targetSectionId) as { id: string }[]
).map((row) => row.id);
const remainingSourceIds = sourceIds.filter((id) => id !== usageId);
normalizePositions(database, 'piece_usages', sourceIds);
normalizePositions(database, 'piece_usages', targetIds);
const temporaryPosition = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS position FROM piece_usages WHERE show_section_id = ?'
)
.get(targetSectionId) as { position: number }
).position;
database
.prepare(
`UPDATE piece_usages SET show_section_id = ?, position = ?, is_reserve = ? WHERE id = ?`
)
.run(targetSectionId, temporaryPosition, input.sectionId === null ? 1 : 0, usageId);
targetIds.splice(validPosition(input.position, targetIds.length), 0, usageId);
normalizePositions(database, 'piece_usages', remainingSourceIds);
normalizePositions(database, 'piece_usages', targetIds);
}
return result;
})();
}
+4 -1
View File
@@ -1,3 +1,6 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user }); export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/')
});
+3 -1
View File
@@ -31,7 +31,9 @@
/> />
</svelte:head> </svelte:head>
{#if data.user} {#if data.presentationMode}
{@render children()}
{:else if data.user}
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite"> <a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
+6 -2
View File
@@ -34,6 +34,10 @@ function text(data: FormData, name: string) {
return String(data.get(name) || ''); return String(data.get(name) || '');
} }
function durationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60);
}
function pieceInput(data: FormData): PieceInput { function pieceInput(data: FormData): PieceInput {
const labels = data.getAll('sourceLabel').map(String); const labels = data.getAll('sourceLabel').map(String);
const urls = data.getAll('sourceUrl').map(String); const urls = data.getAll('sourceUrl').map(String);
@@ -55,7 +59,7 @@ function pieceInput(data: FormData): PieceInput {
summary: text(data, 'summary'), summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null, formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus, status: text(data, 'status') as EditablePieceStatus,
durationSeconds: Number(data.get('durationSeconds')), durationSeconds: durationInSeconds(data),
intro: text(data, 'intro'), intro: text(data, 'intro'),
researchText: text(data, 'researchText'), researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'), moderationNotes: text(data, 'moderationNotes'),
@@ -77,7 +81,7 @@ function saveError(cause: unknown) {
}; };
if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' }; if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' };
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Die Dauer muss zwischen 0 und 86.400 Sekunden liegen.' }; return { status: 400, error: 'Die Dauer muss zwischen 0 und 1.440 Minuten liegen.' };
if (code === 'INVALID_SOURCES') if (code === 'INVALID_SOURCES')
return { return {
status: 400, status: 400,
+6 -5
View File
@@ -8,6 +8,7 @@
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece }))); let draft = $state(untrack(() => ({ ...data.piece })));
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 rendered = $state(untrack(() => ({ ...data.rendered }))); let rendered = $state(untrack(() => ({ ...data.rendered })));
@@ -156,13 +157,13 @@
></label ></label
> >
<label <label
>Dauer in Sekunden<input >Dauer in Minuten<input
name="durationSeconds" name="durationMinutes"
type="number" type="number"
min="0" min="0"
max="86400" max="1440"
step="1" step="any"
bind:value={draft.durationSeconds} bind:value={durationMinutes}
/></label /></label
> >
<label class="wide-field" <label class="wide-field"
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPresentationView } from '#lib/server/live.js';
export const load: PageServerLoad = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return { view };
};
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let view = $state(untrack(() => data.view));
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected');
onMount(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
try {
const response = await fetch(`/heinzlive/${page.params.token}/state`, {
headers: { Accept: 'application/json' },
cache: 'no-store'
});
if (!response.ok) throw new Error('STATE_UNAVAILABLE');
const next = await response.json();
if (next.revision !== view.revision) view = next;
connection = 'connected';
} catch {
connection = 'interrupted';
}
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
}
connection = 'waiting';
timer = setTimeout(refresh, 500);
return () => {
stopped = true;
clearTimeout(timer);
};
});
</script>
<svelte:head>
<title>heinzlive</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class="heinzlive-output">
<div class="output-connection" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div>
{#if view.content}
<article class="output-content">
<p class="output-kicker">heinzlive</p>
<h1>{view.content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html view.content.html}</div>
{#if view.content.images.length}<div class="output-images">
{#each view.content.images as image (image.id)}
<img src={`/heinzlive/${page.params.token}/media/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if view.content.sources.length}<footer class="output-sources">
<span>Quellen</span>
{#each view.content.sources as source, index (`${index}-${source.url}`)}
<!-- Source URLs are validated as absolute HTTP(S) URLs when the Piece is saved. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{: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}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationMedia } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const media = await getPresentationMedia(params.token, params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -0,0 +1,9 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationView } from '#lib/server/live.js';
export const GET: RequestHandler = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } });
};
+125 -13
View File
@@ -1,15 +1,127 @@
import { getShow } from '#lib/server/previews.js'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
completeCurrent,
getLiveView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
skipCurrent,
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
export function load() { function text(data: FormData, name: string) {
const show = getShow(); return String(data.get(name) || '');
const usages = show.sections.flatMap((section) =>
section.usages
.filter((usage) => !usage.isReserve)
.map((usage) => ({ ...usage, section: section.name }))
);
const currentIndex = Math.max(
0,
usages.findIndex((usage) => usage.status === 'current')
);
return { show, current: usages[currentIndex], next: usages[currentIndex + 1] ?? null };
} }
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
status: 409,
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
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.' };
}
function attempt(intent: string, operation: () => unknown) {
try {
return { intent, success: true, result: operation() };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent, ...problem });
}
}
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
try {
return { live: getLiveView(showId), commandId: randomUUID() };
} catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden');
throw cause;
}
};
export const actions = {
start: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('start', () =>
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
complete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('complete', () =>
completeCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
skip: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('skip', () =>
skipCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
back: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('back', () =>
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
present: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('present', () =>
presentUsage(
text(data, 'showId'),
text(data, 'usageId') || null,
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
moveUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveUsage', () =>
moveOpenUsage(text(data, 'showId'), text(data, 'usageId'), Number(data.get('showRevision')), {
sectionId: text(data, 'sectionId') || null,
position: Number(data.get('position'))
})
);
},
createOutput: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const token = issuePresentationAccess(text(data, 'showId'));
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem });
}
}
} satisfies Actions;
+361 -64
View File
@@ -1,92 +1,389 @@
<script lang="ts"> <script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte'; import { enhance, type SubmitFunction } from '$app/forms';
import { formatClock } from '#lib/format.js';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PageData } from './$types'; import { onMount, untrack } from 'svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props(); let { data, form }: PageProps = $props();
let promptSize = $state(1.55); let promptSize = $state(1.55);
let showRail = $state(true);
let showTimes = $state(true);
let popupMessage = $state('');
let now = $state(Date.now());
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let outputWindow: Window | null = null;
let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
$effect(() => {
const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId) {
currentId = nextCurrentId;
loadedAt = Date.now();
}
});
onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`;
try {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
if (typeof settings.scrollY === 'number')
requestAnimationFrame(() => scrollTo(0, settings.scrollY));
} catch {
localStorage.removeItem(key);
}
const interval = setInterval(() => (now = Date.now()), 1000);
const persistScroll = () => persistSettings();
addEventListener('scroll', persistScroll, { passive: true });
return () => {
clearInterval(interval);
removeEventListener('scroll', persistScroll);
};
});
function persistSettings() {
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({ promptSize, showRail, showTimes, scrollY })
);
}
function changePromptSize(delta: number) {
promptSize = Math.min(2.4, Math.max(1.1, promptSize + delta));
persistSettings();
}
function toggleRail() {
showRail = !showRail;
persistSettings();
}
function toggleTimes() {
showTimes = !showTimes;
persistSettings();
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) {
command.value = crypto.randomUUID();
formData.set('commandId', command.value);
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ update }) => {
await update({ reset: false, invalidateAll: true });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
const enhanceOutput: SubmitFunction = ({ submitter }) => {
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') {
const response = result.data as { outputUrl: string };
if (outputWindow && !outputWindow.closed) {
outputWindow.location.href = response.outputUrl;
popupMessage = 'Präsentationsfenster verbunden.';
} else {
popupMessage = `Präsentationsfenster konnte nicht geöffnet werden: ${response.outputUrl}`;
}
} else {
outputWindow?.close();
}
await update({ reset: false, invalidateAll: false });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
</script> </script>
<svelte:head><title>Live-Vorschau | Sendewerk</title></svelte:head> <svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
<div class="page-wrap live-page"> <div class="page-wrap live-page live-controller">
<header class="live-header"> <header class="live-header">
<div> <div>
<p class="eyebrow"><span class="live-pulse"></span> Live-Modus · Vorschau</p> <p class="eyebrow"><span class="live-pulse"></span> Live-Steuerung</p>
<h1>{data.show.title}</h1> <h1>{data.live.show.title}</h1>
<p class="live-state-line">
{data.live.startedAtUtc
? 'Live-Stand wird automatisch gespeichert.'
: 'Noch nicht gestartet.'}
</p>
</div> </div>
<div class="live-header-actions"> <div class="live-header-actions">
<div class="font-control" aria-label="Prompter-Schriftgröße"> <div class="font-control" aria-label="Prompter-Schriftgröße">
<button <button
type="button" type="button"
onclick={() => (promptSize = Math.max(1.15, promptSize - 0.1))} onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button aria-label="Schrift verkleinern">A−</button
> >
<button <button type="button" onclick={() => changePromptSize(0.1)} aria-label="Schrift vergrößern"
type="button" >A+</button
onclick={() => (promptSize = Math.min(2.1, promptSize + 0.1))}
aria-label="Schrift vergrößern">A+</button
> >
</div> </div>
<a href={resolve('/sendungen')}>Planung schließen</a> <button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}
>{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}</button
>
<button type="button" class="text-button" aria-pressed={!showRail} onclick={toggleRail}
>{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}</button
>
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}>Zur Planung</a>
</div> </div>
</header> </header>
<PreviewNotice area="Live-Modus" /> {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
<div class="live-grid"> <section class="live-toolbar" aria-label="Live-Sitzung">
<section class="live-current" style={`--prompt-size: ${promptSize}rem`}> <form method="POST" action="?/start" use:enhance={enhanceCommand}>
<div class="live-kicker"> <input type="hidden" name="showId" value={data.live.show.id} />
<span>Aktuell · {data.current.section}</span><strong <input type="hidden" name="revision" value={data.live.liveRevision} />
>{formatClock(data.current.durationSeconds)}</strong <input type="hidden" name="commandId" value={data.commandId} />
> <button class="primary-button" type="submit">
</div> {data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
<h2>{data.current.title}</h2> </button>
<div class="prompter-copy">{data.current.intro}</div> </form>
<div class="moderation-card"> <form method="POST" action="?/createOutput" use:enhance={enhanceOutput}>
<span>Moderationsstichpunkte</span> <input type="hidden" name="showId" value={data.live.show.id} />
<p>{data.current.moderation}</p> <button class="secondary-button" type="submit">Präsentationsfenster öffnen</button>
</div> </form>
<details class="research-details"> <div class="live-session-summary">
<summary>Recherche öffnen</summary> <strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span>
</div>
</section>
<div class:without-rail={!showRail} class="live-grid">
{#if data.live.current}<main
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
>
<div class="live-kicker">
<span>Aktuell · {data.live.current.sectionName}</span>
{#if showTimes}<strong
>{formatClock(currentActualSeconds)} / {formatClock(
data.live.current.durationSeconds
)}</strong
>{/if}
</div>
<h2 id="current-title">{data.live.current.title}</h2>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
{#if data.live.current.moderationHtml}<section class="moderation-card">
<span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
</section>{/if}
<details class="research-details">
<summary>Manuelle Recherche öffnen</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
</details>
{#if data.live.current.transitionHtml}<section class="transition-card">
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
</section>{/if}
<div class="live-current-actions">
<form method="POST" action="?/back" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zurück</button>
</form>
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
</form>
<form method="POST" action="?/skip" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Überspringen</button>
</form>
<form method="POST" action="?/complete" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">Erledigen und weiter</button>
</form>
</div>
</main>{:else}<main class="live-current live-empty">
<p class="eyebrow">Kein aktives Piece</p>
<h2>{data.live.totalCount ? 'Live-Stand bereit' : 'Noch kein Piece eingeplant'}</h2>
<p> <p>
Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim {data.live.totalCount
Öffnen die Leseposition. ? 'Sendung starten oder einen abgeschlossenen Schritt zurückgehen.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
</p> </p>
</details> {#if data.live.completedCount}<form
<div class="transition-card"> method="POST"
<span>Überleitung</span> action="?/back"
<p>{data.current.transition}</p> use:enhance={enhanceCommand}
</div> >
</section> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zum letzten Piece zurück</button>
</form>{/if}
</main>{/if}
<aside class="live-rail"> {#if showRail}<aside class="live-rail">
<div class="next-card"> <section class="next-card">
<span>Als Nächstes</span> <span>Als Nächstes</span>
{#if data.next} {#if data.live.next}
<h3>{data.next.title}</h3> <h3>{data.live.next.title}</h3>
<p>{data.next.section} · {formatClock(data.next.durationSeconds)}</p> <p>
{:else} {data.live.next.sectionName}{#if showTimes}
<h3>Sendungsende</h3> · {formatClock(data.live.next.durationSeconds)}{/if}
<p>Kein weiteres Stück eingeplant.</p> </p>
{/if} {:else}
</div> <h3>Sendungsende</h3>
<div class="live-progress"> <p>Kein weiteres offenes Piece eingeplant.</p>
<header><span>Ablauf</span><strong>2 / 4</strong></header> {/if}
<div class="progress-track"><span></span></div> </section>
{#each data.show.sections as section, index (section.id)}
<div class:active={index === 1} class="rail-section"> <section class="live-progress">
<span>{String(index + 1).padStart(2, '0')}</span> <header>
<div> <span>Ablauf</span><strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<strong>{section.name}</strong><small>{formatClock(section.durationSeconds)}</small> </header>
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
{#each data.live.sections as section, sectionIndex (section.id)}
<div class:active={section.id === data.live.current?.sectionId} class="rail-section">
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong>
{#if showTimes}<small>{formatDuration(section.durationSeconds)}</small>{/if}
</div>
</div> </div>
</div> {#each section.usages as usage, usageIndex (usage.id)}
{/each} <div class:current={usage.id === data.live.current?.id} class="live-run-item">
</div> <div>
<div class="readonly-actions" aria-label="Spätere Live-Aktionen"> <strong>{usage.title}</strong><small>{usage.status}</small>
<span>Zurück</span><strong>In Etappe 5 steuerbar</strong><span>Erledigen</span> </div>
</div> <div class="live-run-actions">
</aside> {#if usage.status === 'open'}
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex - 1} />
<button
type="submit"
disabled={usageIndex === 0 ||
section.usages[usageIndex - 1]?.status !== 'open'}
aria-label={`${usage.title} nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex + 1} />
<button
type="submit"
disabled={usageIndex === section.usages.length - 1 ||
section.usages[usageIndex + 1]?.status !== 'open'}
aria-label={`${usage.title} nach unten`}>↓</button
>
</form>
{/if}
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button type="submit" aria-label={`${usage.title} präsentieren`}>↗</button>
</form>
</div>
</div>
{/each}
{/each}
</section>
<section class="live-reserve">
<header><span>Reserve</span><strong>{data.live.reserve.length}</strong></header>
{#each data.live.reserve as usage, reserveIndex (usage.id)}
<div class="live-reserve-item">
<div>
<strong>{usage.title}</strong>{#if showTimes}<small
>{formatClock(usage.durationSeconds)}</small
>{/if}
</div>
<div class="live-run-actions">
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex - 1} />
<button
type="submit"
disabled={reserveIndex === 0}
aria-label={`${usage.title} in Reserve nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex + 1} />
<button
type="submit"
disabled={reserveIndex === data.live.reserve.length - 1}
aria-label={`${usage.title} in Reserve nach unten`}>↓</button
>
</form>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action="?/moveUsage"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input
type="hidden"
name="sectionId"
value={data.live.current?.sectionId ?? data.live.sections[0]?.id}
/>
<input
type="hidden"
name="position"
value={(data.live.current?.position ?? -1) + 1}
/>
<button class="text-button" type="submit">Als Nächstes</button>
</form>{/if}
</div>
</div>
{/each}
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
</section>
</aside>{/if}
</div> </div>
</div> </div>
+72 -3
View File
@@ -1,5 +1,74 @@
import { getPieces, getShow } from '#lib/server/previews.js'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
export function load() { function berlinLocalToUtc(value: string): string | null {
return { pieces: getPieces(), show: getShow() }; if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
} }
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() };
};
export const actions = {
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const title = String(data.get('title') || '');
const startsAt = String(data.get('startsAt') || '');
const templateId = String(data.get('templateId') || '') || null;
let id: string;
try {
id = createShow({ title, startsAtUtc: berlinLocalToUtc(startsAt), templateId }).id;
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'INVALID_TITLE') {
return fail(400, { error: 'Bitte einen Titel eingeben.', title, startsAt, templateId });
}
if (code === 'INVALID_TIMESTAMP') {
return fail(400, {
error: 'Bitte einen gültigen Termin eingeben.',
title,
startsAt,
templateId
});
}
return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.',
title,
startsAt,
templateId
});
}
redirect(303, `/sendungen/${id}`);
}
} satisfies Actions;
+84 -110
View File
@@ -1,130 +1,104 @@
<script lang="ts"> <script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PageData } from './$types'; import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props(); let { data, form }: PageProps = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let plannedCount = $derived( function berlinDate(value: string | null) {
data.show.sections.reduce( if (!value) return 'Termin offen';
(total, section) => total + section.usages.filter((usage) => !usage.isReserve).length, return new Intl.DateTimeFormat('de-DE', {
0 timeZone: 'Europe/Berlin',
) dateStyle: 'medium',
); timeStyle: 'short'
}).format(new Date(value));
}
</script> </script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page"> <div class="page-wrap shows-page">
<header class="page-heading show-heading"> <header class="page-heading">
<div class="show-heading-copy"> <div>
<h1>Sendungen</h1> <h1>Sendungen</h1>
<p class="show-title">{data.show.title}</p> <p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
<p class="show-date">{data.show.startsAtBerlin} Uhr</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve('/live')}>Live-Vorschau <span>↗</span></a>
</div> </div>
</header> </header>
<PreviewNotice area="Sendungsbau" /> {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen"> <section class="show-create-panel">
<button <div>
class:active={mobilePane === 'fundus'} <p class="eyebrow">Neuen Stream planen</p>
type="button" <h2>Leer starten oder Ablauf übernehmen</h2>
onclick={() => (mobilePane = 'fundus')}>Fundus</button </div>
> <form method="POST" action="?/create" class="show-create-form">
<button class:active={mobilePane === 'plan'} type="button" onclick={() => (mobilePane = 'plan')} <label
>Ablauf</button >Titel<input
> name="title"
</div> required
maxlength="200"
value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung"
/></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label
>
<label
>Ausgangspunkt<select name="templateId" value={form?.templateId ?? ''}
><option value="">Leer starten</option
>{#each data.templates as template (template.id)}<option value={template.id}
>{template.name}</option
>{/each}</select
></label
>
<button class="primary-button" type="submit">Sendung anlegen</button>
</form>
<div class="template-notes">
{#each data.templates as template (template.id)}<p>
<strong>{template.name}:</strong>
{template.description}
</p>{/each}
</div>
</section>
<div class="builder-workspace"> <section class="show-library" aria-labelledby="show-library-title">
<section <header>
class="builder-pane source-pane" <h2 id="show-library-title">Geplante Sendungen</h2>
class:mobile-hidden={mobilePane !== 'fundus'} <span>{data.shows.length}</span>
aria-labelledby="source-title" </header>
> {#if data.shows.length}
<header class="pane-heading"> <div class="show-card-list">
<div> {#each data.shows as show (show.id)}
<p class="eyebrow">Quelle</p> <a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
<div> <div>
<span class="category-label"><i></i>{piece.category}</span><span <span class={`show-state ${show.status}`}
>{formatClock(piece.durationSeconds)}</span >{show.status === 'ready'
? 'Bereit'
: show.status === 'draft'
? 'Entwurf'
: show.status === 'live'
? 'Live'
: 'Abgeschlossen'}</span
> >
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div> </div>
<h3>{piece.title}</h3> <div class="show-card-facts">
<p>{piece.format} · {piece.topics.join(', ')}</p> <span><strong>{show.sectionCount}</strong> Abschnitte</span><span
</article> ><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
><b aria-hidden="true">→</b>
</div>
</a>
{/each} {/each}
</div> </div>
<div class="reserve-box"> {:else}
<div> <div class="empty-state">
<span aria-hidden="true">◇</span> <span aria-hidden="true">+</span>
<div><strong>Reservebank</strong><small>1 Stück vorbereitet</small></div> <h2>Noch keine Sendung</h2>
</div> <p>Oben kann der erste Stream geplant werden.</p>
<p>„Pixelart, die nicht nostalgisch sein will“ wartet als Ersatz.</p>
</div> </div>
</section> {/if}
</section>
<section
class="builder-pane run-pane"
class:mobile-hidden={mobilePane !== 'plan'}
aria-labelledby="run-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
</header>
<div class="run-list">
{#each data.show.sections as section, index (section.id)}
<article class="run-section" style={`--category: ${section.categoryColor}`}>
<header>
<div class="section-number">{String(index + 1).padStart(2, '0')}</div>
<div>
<span class="category-label"><i></i>{section.category}</span>
<h3>{section.name}</h3>
</div>
<strong>{formatClock(section.durationSeconds)}</strong>
</header>
{#if section.usages.filter((usage) => !usage.isReserve).length > 0}
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Stand für diese Sendung</small
>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
>{usage.status === 'current' ? 'Aktuell' : 'Offen'}</span
>
</div>
{/each}
</div>
{:else}
<div class="section-placeholder">Spieleblock ohne vorbereiteten Fundusinhalt</div>
{/if}
</article>
{/each}
</div>
</section>
</div>
</div> </div>
+222
View File
@@ -0,0 +1,222 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getTaxonomy, listPieces } from '#lib/server/content.js';
import {
addPieceUsage,
addShowSection,
getShow,
movePieceUsage,
moveShowSection,
removePieceUsage,
removeShowSection,
saveShowMetadata,
updatePieceUsage,
updateShowSection,
type EditableShowStatus,
type UsageStatus
} from '#lib/server/shows.js';
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function nullableDuration(data: FormData) {
const value = text(data, 'explicitDurationSeconds').trim();
return value === '' ? null : Number(value);
}
function pieceDurationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60);
}
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
candidate +=
localMilliseconds -
Date.UTC(read('year'), read('month') - 1, read('day'), read('hour'), read('minute'));
}
return new Date(candidate).toISOString();
}
function berlinInput(value: string | null) {
if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(value));
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`;
}
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.',
conflict: true
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' };
}
function attempt(intent: string, operation: () => unknown) {
try {
return { intent, success: 'Änderung gespeichert.', result: operation() };
} catch (cause) {
return fail(actionError(cause).status, { intent, ...actionError(cause) });
}
}
export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user);
const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden');
return {
show,
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: listPieces({
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
}),
taxonomy: getTaxonomy(),
filters: {
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
}
};
};
export const actions = {
saveMetadata: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('saveMetadata', () =>
saveShowMetadata(params.id, Number(data.get('revision')), {
title: text(data, 'title'),
startsAtUtc: berlinLocalToUtc(text(data, 'startsAt')),
twitchTitle: text(data, 'twitchTitle'),
announcementText: text(data, 'announcementText'),
status: text(data, 'status') as EditableShowStatus
})
);
},
addSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('addSection', () =>
addShowSection(params.id, Number(data.get('revision')), {
name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data)
})
);
},
updateSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('updateSection', () =>
updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), {
name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data)
})
);
},
removeSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('removeSection', () =>
removeShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')))
);
},
moveSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveSection', () =>
moveShowSection(
params.id,
text(data, 'sectionId'),
Number(data.get('position')),
Number(data.get('revision'))
)
);
},
addUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('addUsage', () =>
addPieceUsage(params.id, Number(data.get('revision')), {
pieceId: text(data, 'pieceId'),
sectionId: text(data, 'sectionId') || null
})
);
},
updateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('updateUsage', () =>
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
title: text(data, 'title'),
durationSeconds: pieceDurationInSeconds(data),
ownNotes: text(data, 'ownNotes'),
intro: text(data, 'intro'),
research: text(data, 'research'),
moderation: text(data, 'moderation'),
transition: text(data, 'transition'),
presentation: text(data, 'presentation'),
status: text(data, 'status') as UsageStatus
})
);
},
removeUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('removeUsage', () =>
removePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
moveUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveUsage', () =>
movePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
sectionId: text(data, 'sectionId') || null,
position: Number(data.get('position'))
})
);
}
} satisfies Actions;
+872
View File
@@ -0,0 +1,872 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import { flip } from 'svelte/animate';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let dragged = $state<{ kind: 'piece' | 'section' | 'usage'; id: string } | null>(null);
let dropPreview = $state<
| { kind: 'section'; position: number }
| { kind: 'usage'; sectionId: string | null; position: number }
| null
>(null);
let dropTarget = $state<string | null>(null);
let moveSectionForm: HTMLFormElement;
let moveUsageForm: HTMLFormElement;
let addUsageForm: HTMLFormElement;
let draggedSectionId = $state('');
let draggedUsageId = $state('');
let draggedPieceId = $state('');
let dropSectionId = $state('');
let dropPosition = $state(0);
let copyMessage = $state('');
let plannedCount = $derived(
data.show.sections.reduce((total, section) => total + section.usages.length, 0)
);
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date(value));
}
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
function startDrag(kind: 'piece' | 'section' | 'usage', id: string, event: DragEvent) {
dragged = { kind, id };
event.dataTransfer?.setData('text/plain', id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = kind === 'piece' ? 'copy' : 'move';
}
function finishDrag() {
dragged = null;
dropPreview = null;
dropTarget = null;
}
function draggedTitle() {
if (!dragged) return '';
const draggedId = dragged.id;
if (dragged.kind === 'piece') {
return data.pieces.find((piece) => piece.id === draggedId)?.title ?? 'Piece';
}
if (dragged.kind === 'section') {
return data.show.sections.find((section) => section.id === draggedId)?.name ?? 'Abschnitt';
}
for (const section of data.show.sections) {
const usage = section.usages.find((item) => item.id === draggedId);
if (usage) return usage.title;
}
return data.show.reserve.find((usage) => usage.id === draggedId)?.title ?? 'Piece';
}
function positionAtPointer(event: DragEvent, position: number) {
const target = event.currentTarget as HTMLElement;
const bounds = target.getBoundingClientRect();
return event.clientY < bounds.top + bounds.height / 2 ? position : position + 1;
}
function previewSection(event: DragEvent, position: number) {
event.preventDefault();
if (dragged?.kind !== 'section') return;
dropPreview = { kind: 'section', position: positionAtPointer(event, position) };
dropTarget = null;
}
function previewUsage(
event: DragEvent,
sectionId: string | null,
position: number,
atPointer = true
) {
event.preventDefault();
if (!dragged || dragged.kind === 'section') return;
event.stopPropagation();
dropPreview = {
kind: 'usage',
sectionId,
position: atPointer ? positionAtPointer(event, position) : position
};
dropTarget = sectionId ?? 'reserve';
}
function dropAtPreview() {
if (!dropPreview) return;
if (dropPreview.kind === 'section') dropSection(dropPreview.position);
else dropUsage(dropPreview.sectionId, dropPreview.position);
}
function showsUsagePreview(sectionId: string | null, position?: number) {
return (
dropPreview?.kind === 'usage' &&
dropPreview.sectionId === sectionId &&
(position === undefined || dropPreview.position === position)
);
}
function dropSection(position: number) {
if (!dragged || dragged.kind !== 'section') return;
draggedSectionId = dragged.id;
dropPosition = position;
finishDrag();
setTimeout(() => moveSectionForm.requestSubmit());
}
function dropUsage(sectionId: string | null, position: number) {
if (!dragged || dragged.kind === 'section') return;
dropSectionId = sectionId ?? '';
dropPosition = position;
if (dragged.kind === 'piece') {
draggedPieceId = dragged.id;
finishDrag();
setTimeout(() => addUsageForm.requestSubmit());
return;
}
draggedUsageId = dragged.id;
finishDrag();
setTimeout(() => moveUsageForm.requestSubmit());
}
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
const affected = formElement.closest<HTMLElement>(
'.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form'
);
affected?.classList.add('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
await update({ reset: result.type === 'success', invalidateAll: true });
affected?.classList.remove('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
async function copyText(label: string, value: string) {
try {
await navigator.clipboard.writeText(value);
copyMessage = `${label} kopiert.`;
} catch {
copyMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
</script>
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}>← Sendungen</a>
<h1>{data.show.title}</h1>
<p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Pieces</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
>Live-Steuerung <span>↗</span></a
>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings">
<summary>Sendungsdaten und Twitch-Texte</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/saveMetadata"
class="show-settings-form"
>
<input type="hidden" name="revision" value={data.show.revision} />
<label class="wide-field"
>Interner Titel<input
name="title"
required
maxlength="200"
value={data.show.title}
/></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={data.startsAtLocal} /><small
>Die Anzeige erfolgt immer in Europe/Berlin.</small
></label
>
<label
>Status<select name="status" value={data.show.status}
><option value="draft">Entwurf</option><option value="ready">Bereit</option><option
value="live">Live</option
></select
></label
>
<label class="wide-field"
>Twitch-Titel<span class="copy-field"
><input name="twitchTitle" maxlength="200" value={data.show.twitchTitle} /><button
type="button"
onclick={() => copyText('Twitch-Titel', data.show.twitchTitle)}>Kopieren</button
></span
></label
>
<label class="wide-field"
>Ankündigung<span class="copy-field"
><textarea name="announcementText" maxlength="20000" rows="4"
>{data.show.announcementText}</textarea
><button type="button" onclick={() => copyText('Ankündigung', data.show.announcementText)}
>Kopieren</button
></span
></label
>
<div class="wide-field settings-submit">
<span>{copyMessage}</span><button class="primary-button" type="submit"
>Sendungsdaten speichern</button
>
</div>
</form>
</details>
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button
class:active={mobilePane === 'fundus'}
aria-pressed={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
>
<button
class:active={mobilePane === 'plan'}
aria-pressed={mobilePane === 'plan'}
type="button"
onclick={() => (mobilePane = 'plan')}>Ablauf</button
>
</div>
<div class="builder-workspace">
<section
class="builder-pane source-pane"
class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{data.pieces.length}</span>
</header>
<form method="GET" class="builder-filter">
<label
><span class="sr-only">Fundus durchsuchen</span><input
type="search"
name="q"
value={data.filters.query}
placeholder="Fundus durchsuchen"
/></label
>
<label
><span class="sr-only">Abschnitt filtern</span><select name="category"
><option value="">Alle Abschnitte</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={data.filters.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<button class="text-button" type="submit">Filtern</button>
</form>
<div class="builder-source-list">
{#each data.pieces as piece (piece.id)}
<article
class="builder-source-card"
class:is-dragging={dragged?.kind === 'piece' && dragged.id === piece.id}
style={`--category:${piece.categories[0]?.color ?? '#8b8f98'}`}
>
<div
class="source-drag-copy"
role="group"
aria-label={`${piece.title} in den Ablauf ziehen`}
draggable="true"
ondragstart={(event) => startDrag('piece', piece.id, event)}
ondragend={finishDrag}
>
<div>
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
><span>{formatClock(piece.durationSeconds)}</span>
</div>
<h3>{piece.title}</h3>
<p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
</p>
<small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/addUsage"
class="source-add-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="pieceId"
value={piece.id}
/>
<label
><span class="sr-only">Ziel für {piece.title}</span><select
name="sectionId"
aria-label={`Ziel für ${piece.title}`}
><option value="">Reservebank</option
>{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Hinzufügen</button>
</form>
</article>
{/each}
</div>
{#if !data.pieces.length}<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Nichts gefunden</h2>
<p>Filter ändern oder Pieces im Fundus sendefertig markieren.</p>
</div>{/if}
</section>
<section
class="builder-pane run-pane"
class:mobile-hidden={mobilePane !== 'plan'}
aria-labelledby="run-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
</header>
<div class="run-list">
{#each data.show.sections as section, index (section.id)}
<div
class="section-drag-slot"
role="presentation"
ondragover={(event) => {
event.preventDefault();
event.stopPropagation();
}}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
animate:flip={{ duration: 180 }}
>
{#if dropPreview?.kind === 'section' && dropPreview.position === index}
<div class="drag-insert-preview section-insert-preview" data-drop-preview="section">
<span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div>
{/if}
<article
class="run-section editable-run-section"
class:is-dragging={dragged?.kind === 'section' && dragged.id === section.id}
class:drop-ready={dropTarget === section.id}
style={`--category:${section.categoryColor ?? '#8b8f98'}`}
ondragover={(event) => {
if (dragged?.kind === 'section') previewSection(event, index);
else previewUsage(event, section.id, section.usages.length, false);
}}
ondrop={dropAtPreview}
>
<header
class="section-drag-handle"
role="group"
aria-label={`${section.name} verschieben`}
draggable="true"
ondragstart={(event) => startDrag('section', section.id, event)}
ondragend={finishDrag}
>
<div class="section-number">{String(index + 1).padStart(2, '0')}</div>
<div>
<span class="category-label"><i></i>{section.category ?? 'Offen'}</span>
<h3>{section.name}</h3>
</div>
<strong>{formatClock(section.durationSeconds)}</strong>
</header>
<div class="row-actions" aria-label={`Abschnitt ${section.name} verschieben`}>
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input type="hidden" name="position" value={index - 1} /><button
type="submit"
disabled={index === 0}
aria-label={`${section.name} nach oben`}>↑</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input type="hidden" name="position" value={index + 1} /><button
type="submit"
disabled={index === data.show.sections.length - 1}
aria-label={`${section.name} nach unten`}>↓</button
>
</form>
</div>
{#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)}
<div
class="usage-drag-slot"
role="presentation"
ondragover={(event) => {
event.preventDefault();
event.stopPropagation();
}}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
animate:flip={{ duration: 180 }}
>
{#if showsUsagePreview(section.id, usageIndex)}
<div
class="drag-insert-preview usage-insert-preview"
data-drop-preview="usage"
>
<strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div>
{/if}
<div
class="usage-row editable-usage"
class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id}
role="group"
aria-label={`Verwendung ${usage.title}`}
ondragover={(event) => previewUsage(event, section.id, usageIndex)}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
>
<div
class="usage-drag-copy"
role="group"
aria-label={`${usage.title} verschieben`}
draggable="true"
ondragstart={(event) => {
event.stopPropagation();
startDrag('usage', usage.id, event);
}}
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · sendungsspezifische Kopie</small
>
</div>
<div class="row-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="sectionId" value={section.id} /><input
type="hidden"
name="position"
value={usageIndex - 1}
/><button
type="submit"
disabled={usageIndex === 0}
aria-label={`${usage.title} nach oben`}>↑</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="sectionId" value={section.id} /><input
type="hidden"
name="position"
value={usageIndex + 1}
/><button
type="submit"
disabled={usageIndex === section.usages.length - 1}
aria-label={`${usage.title} nach unten`}>↓</button
>
</form>
</div>
<details class="usage-editor">
<summary>Bearbeiten und verschieben</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<label
>Titel<input
name="title"
required
maxlength="200"
value={usage.title}
/></label
><label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="2"
>{usage.ownNotes}</textarea
></label
><label class="wide-field"
>Einleitung<textarea name="intro" rows="2">{usage.intro}</textarea
></label
><label class="wide-field"
>Recherche<textarea name="research" rows="2"
>{usage.research}</textarea
></label
><label class="wide-field"
>Moderation<textarea name="moderation" rows="2"
>{usage.moderation}</textarea
></label
><label class="wide-field"
>Überleitung<textarea name="transition" rows="2"
>{usage.transition}</textarea
></label
><label class="wide-field"
>Präsentation<textarea name="presentation" rows="2"
>{usage.presentation}</textarea
></label
>
<input type="hidden" name="status" value={usage.status} /><button
class="primary-button wide-field"
type="submit">Verwendung speichern</button
>
</form>
<div class="usage-command-row">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="position"
value="9999"
/><label
>Ziel<select name="sectionId"
><option value="">Reservebank</option
>{#each data.show.sections as target (target.id)}<option
value={target.id}
selected={target.id === section.id}>{target.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') &&
event.preventDefault()}
>
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><button
class="text-button danger-text"
type="submit">Entfernen</button
>
</form>
</div>
</details>
</div>
</div>
{/each}
{#if showsUsagePreview(section.id, section.usages.length)}
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div>
{/if}
</div>{:else}
{#if showsUsagePreview(section.id, 0)}
<div class="usage-list">
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div>
</div>
{/if}
<div
class="section-placeholder"
role="group"
aria-label={`Leerer Abschnitt ${section.name}`}
ondragover={(event) => previewUsage(event, section.id, 0, false)}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
>
Leerer Abschnitt · Piece aus dem Fundus hinzufügen oder hierher ziehen
</div>
{/if}
<details class="section-editor">
<summary>Abschnitt bearbeiten</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateSection"
class="section-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><label
>Name<input name="name" required maxlength="200" value={section.name} /></label
><label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={section.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
><label
>Explizite Dauer in Sekunden<input
name="explicitDurationSeconds"
type="number"
min="0"
max="86400"
value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces"
/></label
><button class="secondary-button" type="submit">Abschnitt speichern</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeSection"
onsubmit={(event) =>
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><button class="text-button danger-text" type="submit"
>Abschnitt entfernen</button
>
</form>
</details>
</article>
</div>
{/each}
{#if dropPreview?.kind === 'section' && dropPreview.position === data.show.sections.length}
<div
class="drag-insert-preview section-insert-preview"
data-drop-preview="section"
role="group"
aria-label={`${draggedTitle()} hier einfügen`}
ondragover={(event) => event.preventDefault()}
ondrop={dropAtPreview}
>
<span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div>
{/if}
</div>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/addSection"
class="add-section-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><label
>Name<input name="name" required maxlength="200" placeholder="Neuer Abschnitt" /></label
><label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
><label
>Explizite Dauer <input
name="explicitDurationSeconds"
type="number"
min="0"
max="86400"
placeholder="optional, Sekunden"
/></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form>
<section
class="reserve-box editable-reserve"
class:drop-ready={dropTarget === 'reserve'}
aria-label="Reservebank"
ondragover={(event) => previewUsage(event, null, data.show.reserve.length, false)}
ondrop={dropAtPreview}
>
<header>
<div>
<span aria-hidden="true">◇</span>
<div>
<strong>Reservebank</strong><small
>{data.show.reserve.length}
{data.show.reserve.length === 1 ? 'Piece' : 'Pieces'} · zählt nicht zur Planzeit</small
>
</div>
</div>
</header>
{#each data.show.reserve as usage, usageIndex (usage.id)}
<div
class="reserve-drag-slot"
role="presentation"
ondragover={(event) => {
event.preventDefault();
event.stopPropagation();
}}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
animate:flip={{ duration: 180 }}
>
{#if showsUsagePreview(null, usageIndex)}
<div class="drag-insert-preview reserve-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier in die Reserve einfügen</small>
</div>
{/if}
<div
class="reserve-usage"
class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id}
role="group"
aria-label={`Reserve ${usage.title}`}
ondragover={(event) => previewUsage(event, null, usageIndex)}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
>
<div
class="usage-drag-copy"
role="group"
aria-label={`${usage.title} verschieben`}
draggable="true"
ondragstart={(event) => startDrag('usage', usage.id, event)}
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" /><label
><span class="sr-only">{usage.title} einplanen in</span><select
name="sectionId"
required
><option value="">Abschnitt wählen</option
>{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Einplanen</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/removeUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><button
class="text-button danger-text"
type="submit"
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button
>
</form>
</div>
</div>
{/each}
{#if showsUsagePreview(null, data.show.reserve.length)}
<div class="drag-insert-preview reserve-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier in die Reserve einfügen</small>
</div>
{/if}
{#if !data.show.reserve.length}<p>
Pieces lassen sich hier parken, ohne die geplante Dauer zu erhöhen.
</p>{/if}
</section>
</section>
</div>
<form
bind:this={moveSectionForm}
use:enhance={enhanceCommand}
method="POST"
action="?/moveSection"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={draggedSectionId}
/><input type="hidden" name="position" value={dropPosition} />
</form>
<form
bind:this={moveUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/moveUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={draggedUsageId}
/><input type="hidden" name="sectionId" value={dropSectionId} /><input
type="hidden"
name="position"
value={dropPosition}
/>
</form>
<form
bind:this={addUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/addUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="pieceId"
value={draggedPieceId}
/><input type="hidden" name="sectionId" value={dropSectionId} />
</form>
</div>
+235 -14
View File
@@ -37,6 +37,12 @@ async function login(page: Page, email = adminEmail, password = adminPassword) {
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
} }
async function openSeededShow(page: Page) {
await page.goto('/sendungen');
await page.getByRole('link', { name: /Herbstsendung: Kleine Welten/ }).click();
await expect(page).toHaveURL(/\/sendungen\/show-october$/);
}
function latestMailLink(subject: string): string { function latestMailLink(subject: string): string {
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR; const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.'); if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
@@ -203,11 +209,10 @@ test('desktop builder keeps the fundus beside the wider run sheet', async ({ pag
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
await setTheme(page, 'dark'); await setTheme(page, 'dark');
await page.goto('/sendungen'); await openSeededShow(page);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden(); await expect(page.locator('.mobile-pane-switch')).toBeHidden();
@@ -228,7 +233,7 @@ test('desktop builder keeps the fundus beside the wider run sheet', async ({ pag
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => { test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
await page.setViewportSize({ width: 960, height: 850 }); await page.setViewportSize({ width: 960, height: 850 });
await page.goto('/sendungen'); await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeHidden();
@@ -246,7 +251,7 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
}, testInfo) => { }, testInfo) => {
for (const width of [390, 768]) { for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await page.goto('/sendungen'); await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeHidden(); await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeVisible();
@@ -266,10 +271,139 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
} }
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/sendungen'); await openSeededShow(page);
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
}); });
test('a show can be planned from a template with independent usages and reserve', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Planung Spiele und JC');
await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
const showUrl = page.url();
await expect(page.locator('.run-section')).toHaveCount(5);
const firstSection = page.locator('.run-section').first();
const sectionTransfer = await page.evaluateHandle(() => new DataTransfer());
await firstSection.locator('.section-drag-handle').dispatchEvent('dragstart', {
dataTransfer: sectionTransfer
});
await page.locator('.run-section').nth(1).dispatchEvent('dragover', {
dataTransfer: sectionTransfer
});
await expect(page.locator('[data-drop-preview="section"]')).toContainText('Start');
await expect(firstSection).toHaveClass(/is-dragging/);
await firstSection.locator('.section-drag-handle').dispatchEvent('dragend', {
dataTransfer: sectionTransfer
});
await sectionTransfer.dispose();
await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const source = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await expect(page.locator('.builder-source-card')).toHaveCount(1);
const reactionSection = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) });
await page.locator('body').evaluate((body) => (body.dataset.builderDocument = 'stable'));
const sourceDragHandle = source.locator('.source-drag-copy');
const emptyReactionTarget = reactionSection.locator('.section-placeholder');
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await sourceDragHandle.dispatchEvent('dragstart', { dataTransfer });
await emptyReactionTarget.dispatchEvent('dragover', { dataTransfer });
const insertionPreview = reactionSection.locator('[data-drop-preview="usage"]');
await expect(insertionPreview).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(source).toHaveClass(/is-dragging/);
await expect(source).toHaveCSS('opacity', '0.45');
await emptyReactionTarget.dispatchEvent('drop', { dataTransfer });
await sourceDragHandle.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(1);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage'));
let usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await expect(usages).toHaveCount(2);
await usages.nth(0).locator('summary').click();
await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt');
await usages.nth(0).getByLabel('Dauer in Minuten').fill('5');
await usages.nth(0).getByLabel('Eigene Notizen').fill('Kurbel zuerst zeigen');
await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages.nth(1).locator('summary').click();
await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick');
await usages.nth(1).getByLabel('Dauer in Minuten').fill('15');
await usages.nth(1).getByLabel('Eigene Notizen').fill('Andere Perspektive');
await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
const planTimeBeforeReserve = await page.locator('.show-summary').textContent();
await source.getByLabel(/Ziel für/).selectOption('');
await source.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(page.locator('.reserve-usage')).toHaveCount(1);
expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve);
const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' });
await moveUp.focus();
await page.keyboard.press('Enter');
await expect(
page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage strong')
.first()
).toHaveText('Konsole als Rückblick');
const reserveUsage = page.locator('.reserve-usage');
const reserveHandle = reserveUsage.locator('.usage-drag-copy');
const finalSectionTarget = page.locator('.run-section').last().locator('.section-placeholder');
const reserveTransfer = await page.evaluateHandle(() => new DataTransfer());
await reserveHandle.dispatchEvent('dragstart', { dataTransfer: reserveTransfer });
await finalSectionTarget.dispatchEvent('dragover', { dataTransfer: reserveTransfer });
await expect(page.locator('[data-drop-preview="usage"]')).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveHandle.dispatchEvent('dragend', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await page.reload();
await expect(page.locator('.editable-usage')).toHaveCount(3);
const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' });
await savedUsage.locator('summary').click();
await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click();
await expect(page.getByRole('heading', { name: 'Finales Spiel' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true });
await page.goto('/fundus?q=kleine+Konsole');
await expect(
page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.goto(showUrl);
});
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => { test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) { for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height }); await page.setViewportSize({ width: viewport.width, height: viewport.height });
@@ -352,22 +486,108 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
expect(boxes[1]?.y).toBe(boxes[2]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y);
}); });
test('live preview retains its existing readable controls', async ({ page }) => { test('live control persists state and exposes only explicit presentation data', async ({
page,
browser
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live'); await page.goto('/fundus?q=kleine+Konsole');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'live-konsole.png',
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.goto('/live?show=show-october');
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
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.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
).not.toBeVisible();
await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
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.getByRole('button', { name: 'Schrift vergrößern' }).click(); await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
const after = await page await page.reload();
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(after).not.toBe(before); expect(restored).not.toBe(before);
await page.getByText('Recherche öffnen').click(); await page.getByText('Manuelle Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expect(page.locator('.research-details')).toHaveAttribute('open', '');
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click()
]);
await popup.waitForURL(/\/heinzlive\/[A-Za-z0-9_-]+$/);
const outputUrl = popup.url();
const anonymous = await browser.newContext();
const output = await anonymous.newPage();
await output.goto(outputUrl);
await expect(
output.getByRole('heading', { name: 'Bereit für den nächsten Inhalt' })
).toBeVisible();
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible({
timeout: 10_000
});
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible();
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
const outputHtml = await output.content();
expect(outputHtml).not.toContain('Herstellerangaben');
expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
expect(outputHtml).not.toContain(adminEmail);
expect(outputHtml).not.toContain('live-konsole.png');
const outputState = await anonymous.request.get(`${outputUrl}/state`);
expect(outputState.status()).toBe(200);
const outputJson = await outputState.text();
expect(outputJson).toContain('Weniger Leistung');
expect(outputJson).not.toContain('Herstellerangaben');
expect(outputJson).not.toContain('Moderationsstichpunkte');
expect(outputJson).not.toContain('live-konsole.png');
await page.getByRole('button', { name: 'Als Nächstes' }).click();
await expect(page.locator('.next-card')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Erledigen und weiter' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: 'Zurück' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart');
await output.route('**/state', (route) => route.abort());
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });
await output.unroute('**/state');
await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 });
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('live-controller-mobile.png'),
fullPage: true
});
await page.setViewportSize({ width: 1280, height: 900 });
await page.evaluate(() => {
window.open = () => null;
});
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click();
await expect(
page.getByText('Popup blockiert. Bitte Popups erlauben und erneut öffnen.')
).toBeVisible();
await anonymous.close();
await popup.close();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
@@ -391,7 +611,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.'); .fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Sekunden').fill('615'); await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await page await page
@@ -409,6 +629,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau'); await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload(); await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await expect(page.getByLabel('Private Recherche')).toHaveValue( await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );