3 Commits
Author SHA1 Message Date
hnzio 2851c7d936 Release 0.5.8 2026-10-23 07:47:27 +02:00
hnzio 22ae3a68d6 Release 0.5.7 2026-10-23 00:14:43 +02:00
hnzio dfa236390d Release 0.5.6 2026-10-22 20:54:46 +02:00
33 changed files with 1651 additions and 348 deletions

No files matched your search

+80
View File
@@ -257,3 +257,83 @@
programmatisch geprüft programmatisch geprüft
- vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt - vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt
- `git diff --check`: erfolgreich - `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.6
| Kriterium | Nachweis | Stand |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Gold-/Violettlicht und Glasflächen sichtbar | Fundus und Editor in Light/Dark auf Desktop und Mobil visuell sowie über berechnete Werte geprüft | erfüllt am 22.10.2026 |
| Schreibflächen ruhig und eindeutig abgegrenzt | deckende Feldfarbe, Kontur und goldener Fokus im geöffneten Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Neuanlage und Suche unmittelbar erreichbar | sichtbare Fundusaktionen sowie geöffnete Vorlagenauswahl auf Desktop und Mobil geprüft | erfüllt am 22.10.2026 |
| Zusatzfilter und Zeilenaktionen zugänglich | native Aufklappelemente für Filter und Aktionen per Browserinteraktion geprüft | erfüllt am 22.10.2026 |
| Funduszeilen per Tastatur zu öffnen | erster Titeleintrag fokussiert, mit Eingabetaste geöffnet und Editoraufruf geprüft | erfüllt am 22.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.6
- `npm run check`: 0 Fehler und 0 Warnungen
- gezielter Chromium-Lauf für Fundus und geöffneten Piece-Editor bei
1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne Konsolenfehler oder
horizontalen Seitenüberlauf
- Neuanlage, Zusatzfilter, Aktionsmenü, Tastaturöffnung, Glaswerte und goldener
Fokus automatisiert kontrolliert; alle erzeugten Desktop-/Mobilaufnahmen
visuell geprüft
- Produktionsbuild sowie vollständige Unit- und E2E-Matrix gemäß Auftrag nicht
ausgeführt
- `git diff --check`: erfolgreich
## Materialkorrektur 0.5.7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| Hintergrundlicht im Arbeitsbereich sichtbar | Fundus und Sendungsplanung ohne Popup in Dark/Light auf Desktop und Mobil visuell geprüft | erfüllt am 23.10.2026 |
| Popup erhält die Lichtstimmung | Editor mit leichtem `46%`-Backdrop und `6px` Blur gegen Ansicht ohne Popup verglichen | erfüllt am 23.10.2026 |
| Verschachtelte Ablaufkarten werden dunkler | berechnete RGBA-Werte von Außenglas, Abschnitt, Piece und Aktionsgruppe kontrolliert | erfüllt am 23.10.2026 |
| Graue Sonderflächen entfernt | aktive Tabs, private Recherche, Quellen-/Materialflächen und Aktionsgruppen visuell geprüft | erfüllt am 23.10.2026 |
| Schreibfelder bleiben ruhig und klar | deckende Feldfüllung, feine Kontur und unveränderter Goldfokus in beiden Editorbereichen geprüft | erfüllt am 23.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 23.10.2026 |
## Ausgeführte Prüfungen Materialkorrektur 0.5.7
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- berechnete Styles vor und nach der Korrektur für App-Hintergrund, Navigation,
Ablauf-Panel, Abschnitte, Pieces, Aktionsgruppen, Dialog, Backdrop, Tabs,
Rechercheflächen und Schreibfelder kontrolliert
- gezielter Chromium-Lauf für Sendungsplanung sowie Piece-Editor mit
„Sendungstext“ und „Recherche & Quellen“ bei 1440 × 1000 und 390 × 844 Pixeln
in Light/Dark ohne Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop-/Mobilaufnahmen mit und ohne Popup visuell verglichen
- vollständige Funktionstests gemäß Auftrag nicht ausgeführt
- `git diff --check`: erfolgreich
## Redaktionelle Arbeitsabläufe 0.5.8
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------- |
| Persistente unabhängige Piece-Vorlagen | Servicetests prüfen Snapshot, Revision, Neuanlage, Wiederverwendung und Löschen | erfüllt am 23.10.2026 |
| Atomare idempotente Direktanlage | Verhaltenstests prüfen Wiederholung derselben Befehls-ID und vollständigen Rollback | erfüllt am 23.10.2026 |
| Interner und öffentlicher Titel getrennt | Service- und Browserabläufe prüfen getrennte Werte, Snapshots und Fallback | erfüllt am 23.10.2026 |
| Entfernen betrifft nur konkrete Verwendung | Browser prüft Bestätigung, Rückgabe in Fundus und unverändertes Original | erfüllt am 23.10.2026 |
| Beliebige Live-Verwendung auswählbar | Verhaltenstests wählen ersten und erledigten Eintrag über stabile Usage-ID | erfüllt am 23.10.2026 |
| Stabiler widerrufbarer OBS-Kanal | Servicetests prüfen Wiederöffnung/Rotation; anonymer Browser prüft Fragmenttausch und Cookie | erfüllt am 23.10.2026 |
| Private Quellen und Uploads bleiben intern | HTML- und JSON-Prüfung enthält weder implizite Quellen, Uploadnamen noch interne Redaktionstexte | erfüllt am 23.10.2026 |
| Responsive und gleichwertig bedienbar | Chromium prüft Desktop/Mobil, Light/Dark, Drag-and-drop sowie Klick- und Tastaturalternativen | erfüllt am 23.10.2026 |
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
## Ausgeführte Prüfungen 0.5.8
- `npm run lint`: nach Formatierung erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 33 Tests in 6 Dateien erfolgreich
- `npm run test:e2e`: 15 von 15 Chromium-Browsertests erfolgreich
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, horizontalem Überlauf, Tastaturbedienung, Planung, Live-Steuerung
und anonymer Ausgabe geprüft; Screenshots wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run db:migrate`: Migrationen `0007_editorial_workflows.sql` und
`0008_durable_planning_commands.sql` erfolgreich auf der zuvor gesicherten
lokalen Entwicklungsdatenbank angewendet
- `bash -n start.sh`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen
+26 -7
View File
@@ -41,7 +41,7 @@ und spätere Bereiche.
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | 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; revisionierte Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz | | Sendungsbau | `shows`, geordnete `show_sections`, 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 |
@@ -102,6 +102,13 @@ Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
diese Felder nicht. diese Felder nicht.
Piece-Vorlagen liegen als validierte JSON-Snapshots in `piece_templates`. Jeder
Speichervorgang kopiert den vollständigen Vorlageninhalt tief und erhöht bei
einer Aktualisierung die Revision; spätere Änderungen am Ausgangs-Piece wirken
nicht auf die Vorlage zurück. Die leere Vorlage bleibt virtuell. Interner Titel
und optionaler Präsentationstitel sind getrennte Felder; ohne öffentlichen Titel
verwendet die Präsentationsprojektion den internen Titel als Fallback.
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision. Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
@@ -161,6 +168,11 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern. die Vorlage deshalb nicht verändern.
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
zurückgibt und weder ein zweites Piece noch eine zweite Einplanung erzeugt.
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
@@ -191,12 +203,19 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de
geplanten Dauer nachvollziehbar. geplanten Dauer nachvollziehbar.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im
neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören. rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
erneutes Öffnen behält ihn bei.
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
Positivliste ab. Implizite Piece-Quellen und private Uploads werden nicht
veröffentlicht; nur die ausdrücklich gewählte Link- oder Videoquelle und deren
erlaubte Vorschau-URL können die Grenze passieren.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
+34
View File
@@ -276,3 +276,37 @@ Quellen:
- <https://developers.tiktok.com/doc/embed-player> - <https://developers.tiktok.com/doc/embed-player>
- <https://developers.tiktok.com/doc/embed-videos> - <https://developers.tiktok.com/doc/embed-videos>
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery> - <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
## D-020: Stabile Ausgabekanal-Adresse mit Fragment-Schlüssel
OBS soll eine dauerhaft konfigurierte Adresse verwenden können, ohne dass ein
Zugangsschlüssel in HTTP-Requests, Serverlogs oder Referer-Headern erscheint.
Darum bleibt die Kanaladresse `/heinzlive/default` stabil und trägt den
zufälligen Schlüssel ausschließlich als URL-Fragment. Ein kleiner Browser-POST
tauscht ihn gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie; danach greifen
Seite, Polling und erlaubte Proxys nur mit diesem Cookie zu. Für die Prüfung
liegt ein SHA-256-Hash vor. Der rohe Schlüssel muss zusätzlich in SQLite
gespeichert werden, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
nach einem Neustart erneut ausgeben können muss. Damit sind Datenbank und
Sicherungen bewusst Teil der Geheimnisgrenze. Wiederholtes Öffnen behält den
Schlüssel, eine ausdrückliche Rotation widerruft ihn sofort.
Die öffentliche Projektion folgt einer engeren Positivliste als der interne
Piece-Snapshot. Weder private Uploads noch alle Piece-Quellen werden implizit
veröffentlicht. Passieren dürfen nur öffentlicher Text, Präsentationstitel sowie
die ausdrücklich gewählte und erneut validierte Link- oder Videoquelle mit
erlaubten Vorschaufeldern.
## D-021: Vorlagen-Snapshots und idempotente Direktanlage
Persistente Piece-Vorlagen speichern validierte, tief kopierte JSON-Snapshots
mit eigener Revision. Damit bleiben sie unabhängig von späteren Änderungen am
Ausgangs-Piece, ohne für jedes optionale Piece-Feld eine zweite Tabellenstruktur
zu benötigen. Die virtuelle leere Vorlage wird nicht persistiert.
Eine Direktanlage im Sendungsplan ist ein einzelner fachlicher Befehl. Piece,
Verwendung und Sendungsrevision werden in derselben SQLite-Transaktion
geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
Einplanen getrennt in den Verwendungssnapshot kopiert.
+62
View File
@@ -7,6 +7,68 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen. Etappe 6 wurde nicht begonnen.
## Redaktionelle Arbeitsabläufe 0.5.8
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
Vorlagen lassen sich aus Fundus-Pieces speichern, erneut verwenden und löschen
- atomare, idempotente Piece-Neuanlage direkt in einer Sendung einschließlich
Einplanung; fehlgeschlagene Schritte hinterlassen weder Piece noch Verwendung
- getrennte interne und öffentliche Titel an Piece und Verwendung; ein leerer
Präsentationstitel fällt kontrolliert auf den internen Titel zurück
- präzises Entfernen einzelner Verwendungen mit Bestätigung sowie zusätzlicher
Drag-and-drop-Rückgabe in den Fundusbereich
- Auswahl jedes konkreten Ablauf-Pieces in der Live-Steuerung, einschließlich
erstem und bereits erledigtem Eintrag
- stabiler OBS-Ausgabekanal `/heinzlive/default` mit geheimem URL-Fragment,
HttpOnly-Cookie-Austausch und ausdrücklicher Schlüsselrotation
- öffentliche Ausgabe als Positivliste: implizite Piece-Quellen und private
Uploads werden nicht mehr veröffentlicht; nur ausdrücklich gewählte
Link-/Videoquellen und deren erlaubte Vorschau bleiben sichtbar
Die vollständige lokale Prüfmatrix umfasst 33 Verhaltenstests und 15
Chromium-Abläufe. Desktop, Mobil, Light/Dark, Tastatur- und Touchalternativen,
Planung, Live-Ausgabe sowie anonyme Ausgabedaten wurden geprüft. Reales OBS und
echte YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
## Materialkorrektur 0.5.7
- statische Gold-/Blauviolettlichter so positioniert und verstärkt, dass sie im
eigentlichen Arbeitsbereich statt fast ausschließlich hinter der Navigation
sichtbar werden
- gemeinsame Materialtokens auf Außenglas, dunkle Innenfläche und tiefe
Ablaufkarte reduziert; verschachtelte Abschnitte, Pieces und Aktionsgruppen
werden nach innen dunkler statt heller und blaugrau
- aktive Editortabs transparent goldgetönt und private Recherche sowie
Quellen-/Materialbereiche auf transparente Nachtblauflächen zurückgeführt
- Dialog-Backdrop von starker Abdunklung und Unschärfe auf eine leichtere
Trennung reduziert, sodass die Lichtstimmung bei geöffnetem Editor erhalten
bleibt
- deckende Schreibfelder, Goldfokus, Lightmode, Mobilansicht und lesbare
Komponentenfüllungen ohne verfügbaren Blur bleiben erhalten
Die berechneten Hintergründe, Transparenzen, Filter und Begrenzungseigenschaften
wurden vor und nach der Korrektur in Chromium kontrolliert. Sendungsplanung,
Sendungstext und Recherche wurden bei 1440 × 1000 und 390 × 844 Pixeln in beiden
Themes visuell geprüft.
## Oberflächenkorrektur 0.5.6
- sichtbare statische Gold- und Blauviolettlichter hinter den halbtransparenten
Navigations- und Editorflächen
- klarere Glaswirkung in Navigation sowie Kopf, Tabs und Fuß des Piece-Editors;
Schreibflächen bleiben deckend, ruhig und goldfokussiert
- Fundus mit dauerhaft sichtbarer Suche und Neuanlage, aufklappbaren Zusatzfiltern
und inhaltsorientierten, vollständig anklickbaren Zeilen
- vorhandene Vorschaubilder beziehungsweise kleine Symbolplatzhalter sowie
gebündelte Menüs für Duplizieren und Archivieren
- bestehende Server-Actions, Filterlogik und Editor-Bereiche unverändert
Geprüft wurden `npm run check` sowie Fundus und geöffneter Piece-Editor in
Chromium bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark. Die gezielte
Kontrolle umfasste Tastaturöffnung, Menüs, Fokus, Glaswerte, Seitenüberlauf und
visuell geprüfte Screenshots. Produktionsbuild und vollständige Testmatrix wurden
gemäß Auftrag nicht ausgeführt.
## Oberflächenkorrektur 0.5.5 ## Oberflächenkorrektur 0.5.5
- dunkle Grundfläche mit sehr zurückhaltenden Gold-/Cyan-Lichtfeldern innerhalb - dunkle Grundfläche mit sehr zurückhaltenden Gold-/Cyan-Lichtfeldern innerhalb
+58
View File
@@ -0,0 +1,58 @@
ALTER TABLE pieces ADD COLUMN output_title TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN output_title_snapshot TEXT NOT NULL DEFAULT '';
CREATE TABLE piece_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
snapshot_json TEXT NOT NULL CHECK (json_valid(snapshot_json)),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
INSERT INTO piece_templates (
id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc
) VALUES
(
'talk', 'Gespräch',
'Leitfragen, Recherche und eine klare Überleitung.',
'{"title":"Unbenanntes Gespräch","formatId":"format-talk","moderationNotes":"## Leitfragen\n\n- ","categoryIds":["cat-jc"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
),
(
'find', 'Reaction / Fundstück',
'Quelle, Einordnung und präsentierbare Kernaussage.',
'{"title":"Neues Fundstück","formatId":"format-find","presentationText":"## Kernaussage\n\n","categoryIds":["cat-reaction"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
),
(
'interaction', 'Interaktion',
'Quiz, Publikumsfrage oder kleines Spiel.',
'{"title":"Neue Interaktion","formatId":"format-quiz","moderationNotes":"## Ablauf\n\n1. ","categoryIds":["cat-game"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
);
CREATE TABLE planning_commands (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
CREATE TABLE presentation_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
access_key TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
show_id TEXT REFERENCES shows(id) ON DELETE SET NULL,
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
UPDATE presentation_access_tokens
SET revoked_at_utc = COALESCE(revoked_at_utc, '2026-10-23T00:00:00.000Z');
@@ -0,0 +1,18 @@
CREATE TABLE planning_commands_durable (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
piece_id TEXT NOT NULL,
usage_id TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
INSERT INTO planning_commands_durable (
show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
)
SELECT show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
FROM planning_commands;
DROP TABLE planning_commands;
ALTER TABLE planning_commands_durable RENAME TO planning_commands;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.5", "version": "0.5.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.5", "version": "0.5.8",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.5", "version": "0.5.8",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+420 -80
View File
@@ -16,6 +16,11 @@
--panel-soft: rgb(226 233 242 / 72%); --panel-soft: rgb(226 233 242 / 72%);
--glass: rgb(252 254 255 / 68%); --glass: rgb(252 254 255 / 68%);
--glass-strong: rgb(250 252 255 / 84%); --glass-strong: rgb(250 252 255 / 84%);
--glass-chrome: rgb(252 254 255 / 74%);
--material-inset: rgb(235 241 248 / 82%);
--material-deep: rgb(225 233 243 / 90%);
--ambient-gold: rgb(231 185 87 / 20%);
--ambient-violet: rgb(102 89 184 / 15%);
--field: #f8fbfe; --field: #f8fbfe;
--text: #172033; --text: #172033;
--text-muted: #5d6a7d; --text-muted: #5d6a7d;
@@ -46,11 +51,16 @@
color-scheme: dark; color-scheme: dark;
--bg: #0b1220; --bg: #0b1220;
--bg-elevated: rgb(11 18 32 / 68%); --bg-elevated: rgb(11 18 32 / 68%);
--panel: rgb(18 31 52 / 72%); --panel: rgb(12 24 42 / 72%);
--panel-strong: #111d30; --panel-strong: #111d30;
--panel-soft: rgb(28 43 65 / 62%); --panel-soft: rgb(7 16 30 / 76%);
--glass: rgb(15 27 46 / 62%); --glass: rgb(10 22 39 / 64%);
--glass-strong: rgb(17 30 51 / 82%); --glass-strong: rgb(10 22 39 / 84%);
--glass-chrome: rgb(9 20 36 / 72%);
--material-inset: rgb(6 15 28 / 72%);
--material-deep: rgb(4 11 22 / 82%);
--ambient-gold: rgb(231 185 87 / 32%);
--ambient-violet: rgb(102 89 184 / 24%);
--field: #101a2b; --field: #101a2b;
--text: #fff7e8; --text: #fff7e8;
--text-muted: #a9b6c8; --text-muted: #a9b6c8;
@@ -88,16 +98,8 @@ body {
min-width: 20rem; min-width: 20rem;
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient( radial-gradient(ellipse 52rem 36rem at 14% -6%, var(--ambient-gold), transparent 66%),
ellipse 62rem 38rem at 88% -8%, radial-gradient(ellipse 58rem 44rem at 94% 18%, var(--ambient-violet), transparent 68%),
color-mix(in srgb, var(--gold) 7%, transparent),
transparent 70%
),
radial-gradient(
ellipse 54rem 42rem at 4% 48%,
color-mix(in srgb, var(--accent) 6%, transparent),
transparent 72%
),
var(--bg); var(--bg);
background-attachment: fixed; background-attachment: fixed;
color: var(--text); color: var(--text);
@@ -176,7 +178,10 @@ textarea:focus {
padding: 1.25rem 0.85rem 1rem; padding: 1.25rem 0.85rem 1rem;
border-right: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line)); border-right: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line));
background: var(--glass); background: var(--glass);
box-shadow: 10px 0 34px rgb(0 0 0 / 12%); box-shadow:
inset -1px 0 rgb(255 255 255 / 8%),
10px 0 34px rgb(0 0 0 / 12%);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
backdrop-filter: blur(24px) saturate(1.3); backdrop-filter: blur(24px) saturate(1.3);
overflow-y: auto; overflow-y: auto;
} }
@@ -520,7 +525,7 @@ textarea:focus {
.piece-grid { .piece-grid {
display: grid; display: grid;
gap: 0; gap: 0;
overflow: hidden; overflow: visible;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-medium); border-radius: var(--radius-medium);
background: var(--glass); background: var(--glass);
@@ -759,11 +764,14 @@ textarea:focus {
.builder-pane { .builder-pane {
min-width: 0; min-width: 0;
padding: 0.9rem; padding: 0.9rem;
border: 1px solid var(--line); border: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line));
border-radius: var(--radius-medium); border-radius: var(--radius-medium);
background: var(--glass); background: var(--glass);
box-shadow: var(--shadow-soft); box-shadow:
backdrop-filter: blur(18px) saturate(1.08); inset 0 1px rgb(255 255 255 / 7%),
var(--shadow-soft);
-webkit-backdrop-filter: blur(20px) saturate(1.16);
backdrop-filter: blur(20px) saturate(1.16);
} }
.pane-heading { .pane-heading {
@@ -782,7 +790,7 @@ textarea:focus {
.pane-heading > span { .pane-heading > span {
padding: 0.3rem 0.55rem; padding: 0.3rem 0.55rem;
border-radius: 2rem; border-radius: 2rem;
background: var(--panel-soft); background: var(--material-deep);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 680; font-weight: 680;
@@ -850,13 +858,14 @@ textarea:focus {
.run-list { .run-list {
display: grid; display: grid;
gap: 0; gap: 0.5rem;
border-top: 1px solid var(--line);
} }
.run-section { .run-section {
padding: 0.78rem 0.2rem; padding: 0.78rem;
border-bottom: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--material-inset);
} }
.run-section > header { .run-section > header {
@@ -883,7 +892,7 @@ textarea:focus {
height: 2rem; height: 2rem;
place-items: center; place-items: center;
border-radius: 0.65rem; border-radius: 0.65rem;
background: color-mix(in srgb, var(--category) 16%, var(--panel-soft)); background: color-mix(in srgb, var(--category) 18%, var(--material-deep));
color: var(--text-muted); color: var(--text-muted);
font-size: 0.67rem; font-size: 0.67rem;
font-weight: 750; font-weight: 750;
@@ -903,7 +912,7 @@ textarea:focus {
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 0.72rem; border-radius: 0.72rem;
border: 1px solid transparent; border: 1px solid transparent;
background: color-mix(in srgb, var(--panel-soft) 68%, transparent); background: var(--material-deep);
transition: transition:
background-color 140ms ease, background-color 140ms ease,
border-color 140ms ease; border-color 140ms ease;
@@ -911,8 +920,8 @@ textarea:focus {
.usage-row:hover, .usage-row:hover,
.usage-row:focus-within { .usage-row:focus-within {
border-color: var(--line-strong); border-color: color-mix(in srgb, var(--gold) 34%, var(--line));
background: var(--glass-strong); background: color-mix(in srgb, var(--gold) 4%, var(--material-deep));
} }
.section-drag-handle, .section-drag-handle,
@@ -1233,6 +1242,33 @@ textarea:focus {
text-align: right; text-align: right;
} }
.live-output-settings {
position: relative;
}
.live-output-settings > summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.live-output-settings > div {
position: absolute;
z-index: 4;
top: calc(100% + 0.45rem);
left: 0;
display: grid;
width: max-content;
max-width: calc(100vw - 2rem);
gap: 0.35rem;
padding: 0.6rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--glass-strong);
box-shadow: var(--shadow);
}
.live-session-summary strong { .live-session-summary strong {
font-size: 0.78rem; font-size: 0.78rem;
} }
@@ -2048,24 +2084,77 @@ textarea:focus {
} }
.template-picker { .template-picker {
margin-bottom: 0.85rem; position: relative;
border: 1px solid var(--line); z-index: 8;
border-radius: var(--radius-medium);
background: var(--panel);
} }
.template-picker summary { .template-picker summary {
padding: 0.75rem 0.9rem; display: flex;
min-height: 2.5rem;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.8rem;
border-radius: 0.72rem;
background: var(--primary);
box-shadow: 0 8px 22px color-mix(in srgb, var(--gold) 20%, transparent);
color: var(--primary-text);
cursor: pointer; cursor: pointer;
list-style: none;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 720; font-weight: 720;
} }
.template-picker summary::-webkit-details-marker,
.filter-picker summary::-webkit-details-marker,
.piece-actions summary::-webkit-details-marker {
display: none;
}
.fundus-heading-actions {
display: flex;
align-items: center;
gap: 0.55rem;
}
.template-popover {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: min(42rem, calc(100vw - var(--sidebar-width) - 4rem));
padding: 0.7rem;
border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line));
border-radius: var(--radius-medium);
background: var(--glass-strong);
box-shadow: var(--shadow);
-webkit-backdrop-filter: blur(22px) saturate(1.18);
backdrop-filter: blur(22px) saturate(1.18);
}
.template-grid { .template-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem; gap: 0.6rem;
padding: 0 0.75rem 0.75rem; }
.template-management {
margin-top: 0.6rem;
}
.template-picker .template-management > summary {
min-height: auto;
padding: 0.35rem 0;
background: transparent;
box-shadow: none;
color: var(--text-muted);
}
.template-management form {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.3rem 0;
border-top: 1px solid var(--line);
} }
.template-grid button { .template-grid button {
@@ -2089,14 +2178,65 @@ textarea:focus {
} }
.library-filters { .library-filters {
display: grid; position: relative;
grid-template-columns: minmax(15rem, 1.5fr) repeat(4, minmax(7rem, 0.65fr)) auto auto; z-index: 6;
align-items: end; margin-bottom: 0.85rem;
gap: 0.55rem;
margin-bottom: 0.75rem;
} }
.library-filters > label:not(.search-field) { .fundus-search-row {
display: flex;
align-items: center;
gap: 0.55rem;
}
.fundus-search-row .search-field {
width: min(100%, 34rem);
}
.filter-picker {
position: relative;
}
.filter-picker summary {
display: flex;
min-height: 2.5rem;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.72rem;
border: 1px solid var(--line);
border-radius: 0.72rem;
background: var(--glass);
color: var(--text-muted);
cursor: pointer;
list-style: none;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.filter-picker[open] summary {
border-color: color-mix(in srgb, var(--gold) 46%, var(--line));
color: var(--text);
}
.filter-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
display: grid;
width: min(38rem, calc(100vw - var(--sidebar-width) - 4rem));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
padding: 0.8rem;
border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line));
border-radius: var(--radius-medium);
background: var(--glass-strong);
box-shadow: var(--shadow);
-webkit-backdrop-filter: blur(22px) saturate(1.18);
backdrop-filter: blur(22px) saturate(1.18);
}
.filter-panel > label {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
color: var(--text-muted); color: var(--text-muted);
@@ -2104,7 +2244,7 @@ textarea:focus {
font-weight: 700; font-weight: 700;
} }
.library-filters select { .filter-panel select {
min-width: 0; min-width: 0;
height: 2.5rem; height: 2.5rem;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
@@ -2135,15 +2275,26 @@ textarea:focus {
.library-filter-options { .library-filter-options {
display: flex; display: flex;
grid-column: 1 / -1;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.15rem 1rem; gap: 0.4rem 1rem;
margin: -0.35rem 0 0.75rem; margin: 0;
} }
.library-filter-options .filter-checkbox { .library-filter-options .filter-checkbox {
margin: 0; margin: 0;
} }
.filter-actions {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
padding-top: 0.65rem;
border-top: 1px solid var(--line);
}
.piece-usage-history { .piece-usage-history {
margin-top: 0.35rem; margin-top: 0.35rem;
color: var(--text-muted); color: var(--text-muted);
@@ -2192,9 +2343,14 @@ textarea:focus {
} }
.piece-card { .piece-card {
position: relative;
grid-template-columns: auto minmax(0, 1.65fr) minmax(8rem, 0.85fr) minmax(9rem, auto) auto; grid-template-columns: auto minmax(0, 1.65fr) minmax(8rem, 0.85fr) minmax(9rem, auto) auto;
} }
.piece-card:has(.piece-actions[open]) {
z-index: 5;
}
.piece-thumbnail { .piece-thumbnail {
width: 3.8rem; width: 3.8rem;
height: 3.8rem; height: 3.8rem;
@@ -2207,20 +2363,80 @@ textarea:focus {
place-items: center; place-items: center;
background: var(--panel-soft); background: var(--panel-soft);
color: var(--text-faint); color: var(--text-faint);
font-size: 0.75rem; border: 1px solid var(--line);
font-weight: 800;
} }
.piece-primary h2 a:hover { .piece-thumbnail-placeholder svg {
width: 1.35rem;
height: 1.35rem;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
}
.piece-primary h2 a:hover,
.piece-card:focus-within .piece-primary h2 a {
color: var(--accent); color: var(--accent);
} }
.piece-primary h2 a::after {
position: absolute;
inset: 0;
content: '';
}
.piece-card :is(a, button, input, summary) {
position: relative;
z-index: 2;
}
.piece-actions { .piece-actions {
display: flex; position: relative;
align-items: center; z-index: 4;
flex-wrap: wrap; }
justify-content: flex-end;
gap: 0.55rem; .piece-actions summary {
display: grid;
width: 2.2rem;
height: 2.2rem;
place-items: center;
border: 1px solid var(--line);
border-radius: 0.68rem;
background: var(--glass-strong);
color: var(--text-muted);
cursor: pointer;
list-style: none;
font-size: 0.65rem;
letter-spacing: 0.08em;
}
.piece-actions[open] summary {
border-color: color-mix(in srgb, var(--gold) 46%, var(--line));
color: var(--text);
}
.piece-actions-popover {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 5;
display: grid;
min-width: 9.5rem;
gap: 0.15rem;
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: 0.72rem;
background: var(--glass-strong);
box-shadow: var(--shadow);
-webkit-backdrop-filter: blur(18px) saturate(1.15);
backdrop-filter: blur(18px) saturate(1.15);
}
.piece-actions-popover button {
width: 100%;
justify-content: flex-start;
padding: 0.5rem 0.6rem;
} }
.danger-text { .danger-text {
@@ -3366,7 +3582,7 @@ textarea:focus {
padding: 0.16rem; padding: 0.16rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.7rem; border-radius: 0.7rem;
background: var(--glass-strong); background: color-mix(in srgb, var(--gold) 3%, var(--material-deep));
} }
.row-actions > form > button, .row-actions > form > button,
@@ -3415,9 +3631,10 @@ textarea:focus {
overflow: hidden; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--gold) 58%, var(--line-strong)); border: 1px solid color-mix(in srgb, var(--gold) 58%, var(--line-strong));
border-radius: var(--radius-large); border-radius: var(--radius-large);
background: var(--glass-strong); background: var(--glass);
box-shadow: var(--shadow-large); box-shadow: var(--shadow-large);
color: var(--text); color: var(--text);
-webkit-backdrop-filter: blur(28px) saturate(1.12);
backdrop-filter: blur(28px) saturate(1.12); backdrop-filter: blur(28px) saturate(1.12);
} }
@@ -3426,8 +3643,9 @@ textarea:focus {
} }
.piece-editor-dialog::backdrop { .piece-editor-dialog::backdrop {
background: color-mix(in srgb, var(--bg) 76%, transparent); background: color-mix(in srgb, var(--bg) 46%, transparent);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(6px) saturate(0.95);
backdrop-filter: blur(6px) saturate(0.95);
} }
.piece-dialog-header { .piece-dialog-header {
@@ -3438,7 +3656,9 @@ textarea:focus {
gap: 1rem; gap: 1rem;
padding: 0.9rem 1rem 0.75rem; padding: 0.9rem 1rem 0.75rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--glass-strong) 88%, transparent); background: var(--glass-chrome);
box-shadow: inset 0 1px rgb(255 255 255 / 10%);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.12); backdrop-filter: blur(20px) saturate(1.12);
} }
@@ -3486,7 +3706,8 @@ textarea:focus {
gap: 0.25rem; gap: 0.25rem;
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--glass-strong) 88%, transparent); background: var(--glass-chrome);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.12); backdrop-filter: blur(20px) saturate(1.12);
} }
@@ -3502,8 +3723,8 @@ textarea:focus {
} }
.piece-dialog-tabs button.active { .piece-dialog-tabs button.active {
background: color-mix(in srgb, var(--gold) 13%, var(--panel)); background: color-mix(in srgb, var(--gold) 15%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 36%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 48%, transparent);
color: var(--text); color: var(--text);
} }
@@ -3513,7 +3734,7 @@ textarea:focus {
padding: clamp(0.85rem, 2vw, 1.4rem); padding: clamp(0.85rem, 2vw, 1.4rem);
overflow: auto; overflow: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
background: color-mix(in srgb, var(--bg) 76%, transparent); background: var(--material-deep);
} }
.piece-dialog-form { .piece-dialog-form {
@@ -3576,7 +3797,7 @@ textarea:focus {
padding: 0.85rem; padding: 0.85rem;
border: 1px solid color-mix(in srgb, var(--gold) 30%, var(--line)); border: 1px solid color-mix(in srgb, var(--gold) 30%, var(--line));
border-radius: var(--radius-medium); border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--gold) 5%, var(--glass)); background: var(--material-inset);
} }
.markdown-editor-heading { .markdown-editor-heading {
@@ -3611,7 +3832,7 @@ textarea:focus {
padding: 0.22rem 0.4rem; padding: 0.22rem 0.4rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.45rem; border-radius: 0.45rem;
background: color-mix(in srgb, var(--panel-soft) 72%, transparent); background: var(--material-inset);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.65rem; font-size: 0.65rem;
cursor: pointer; cursor: pointer;
@@ -3626,6 +3847,7 @@ textarea:focus {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: var(--radius-small); border-radius: var(--radius-small);
background: var(--field); background: var(--field);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 5%);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 0.88rem; font-size: 0.88rem;
@@ -3639,7 +3861,7 @@ textarea:focus {
padding: 0.85rem; padding: 0.85rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-medium); border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--glass) 78%, transparent); background: var(--material-inset);
} }
.piece-meta-details summary { .piece-meta-details summary {
@@ -3668,6 +3890,7 @@ textarea:focus {
.piece-meta-grid > label, .piece-meta-grid > label,
.piece-meta-grid fieldset, .piece-meta-grid fieldset,
.piece-source-editor label, .piece-source-editor label,
.piece-presentation-controls > label,
.piece-presentation-controls .presentation-settings > label { .piece-presentation-controls .presentation-settings > label {
display: grid; display: grid;
gap: 0.25rem; gap: 0.25rem;
@@ -3810,8 +4033,11 @@ textarea:focus {
gap: 0.8rem; gap: 0.8rem;
padding: 0.65rem 1rem; padding: 0.65rem 1rem;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
background: color-mix(in srgb, var(--glass-strong) 90%, transparent); background: var(--glass-chrome);
box-shadow: 0 -10px 30px rgb(0 0 0 / 10%); box-shadow:
inset 0 -1px rgb(255 255 255 / 8%),
0 -10px 30px rgb(0 0 0 / 10%);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.12); backdrop-filter: blur(20px) saturate(1.12);
} }
@@ -3828,6 +4054,38 @@ textarea:focus {
gap: 0.3rem; gap: 0.3rem;
} }
.template-save-menu {
position: relative;
}
.template-save-menu > summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.template-save-menu > form {
position: absolute;
z-index: 4;
bottom: calc(100% + 0.7rem);
left: 0;
display: grid;
width: min(28rem, calc(100vw - 2rem));
padding: 0.75rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-medium);
background: var(--glass-strong);
box-shadow: var(--shadow);
}
.template-save-menu label {
display: grid;
gap: 0.2rem;
color: var(--text-muted);
font-size: 0.66rem;
}
.piece-dialog-primary-action span { .piece-dialog-primary-action span {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.66rem; font-size: 0.66rem;
@@ -3996,6 +4254,53 @@ textarea:focus {
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
} }
.plan-piece-create {
margin-bottom: 0.55rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--panel-soft);
}
.plan-piece-create > summary {
cursor: pointer;
font-size: 0.72rem;
font-weight: 750;
}
.plan-piece-create form {
display: grid;
grid-template-columns: minmax(8rem, 1fr) minmax(7rem, 0.8fr);
gap: 0.45rem;
margin-top: 0.6rem;
}
.plan-piece-create label {
display: grid;
gap: 0.2rem;
color: var(--text-muted);
font-size: 0.64rem;
}
.plan-piece-create button {
grid-column: 1 / -1;
}
.drag-remove-target {
display: grid;
gap: 0.15rem;
margin-bottom: 0.55rem;
padding: 0.75rem;
border: 1px dashed var(--danger);
border-radius: var(--radius-small);
background: color-mix(in srgb, var(--danger) 9%, var(--panel-soft));
}
.drag-remove-target span {
color: var(--text-muted);
font-size: 0.68rem;
}
.builder-filter-options { .builder-filter-options {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4327,12 +4632,6 @@ textarea:focus {
} }
@media (max-width: 72rem) { @media (max-width: 72rem) {
.library-filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.library-filters .search-field {
grid-column: 1 / -1;
}
.piece-card { .piece-card {
grid-template-columns: auto minmax(0, 1fr) minmax(8rem, 0.7fr) auto; grid-template-columns: auto minmax(0, 1fr) minmax(8rem, 0.7fr) auto;
} }
@@ -4405,6 +4704,7 @@ textarea:focus {
border-bottom: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line)); border-bottom: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line));
background: var(--glass); background: var(--glass);
box-shadow: 0 8px 28px rgb(0 0 0 / 12%); box-shadow: 0 8px 28px rgb(0 0 0 / 12%);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
backdrop-filter: blur(24px) saturate(1.2); backdrop-filter: blur(24px) saturate(1.2);
} }
.mobile-header .theme-toggle { .mobile-header .theme-toggle {
@@ -4428,6 +4728,7 @@ textarea:focus {
border-top: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line)); border-top: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line));
background: var(--glass); background: var(--glass);
box-shadow: 0 -8px 28px rgb(0 0 0 / 12%); box-shadow: 0 -8px 28px rgb(0 0 0 / 12%);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
backdrop-filter: blur(24px) saturate(1.2); backdrop-filter: blur(24px) saturate(1.2);
} }
.mobile-navigation:has(a:nth-child(5)) { .mobile-navigation:has(a:nth-child(5)) {
@@ -4467,7 +4768,6 @@ textarea:focus {
.template-grid, .template-grid,
.two-columns, .two-columns,
.markdown-grid, .markdown-grid,
.library-filters,
.capture-form, .capture-form,
.upload-form, .upload-form,
.source-row { .source-row {
@@ -4481,7 +4781,6 @@ textarea:focus {
.builder-filter { .builder-filter {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.library-filters .search-field,
.wide-field { .wide-field {
grid-column: auto; grid-column: auto;
} }
@@ -4742,6 +5041,47 @@ textarea:focus {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.split-heading {
align-items: flex-start;
flex-direction: column;
}
.fundus-heading-actions {
width: 100%;
}
.template-picker {
margin-left: auto;
}
.template-popover,
.filter-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
left: auto;
width: calc(100vw - 2rem);
max-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 1rem);
overflow-y: auto;
}
.template-grid {
grid-template-columns: 1fr;
}
.fundus-search-row {
align-items: stretch;
flex-wrap: wrap;
}
.fundus-search-row .search-field {
width: 100%;
flex: 1 0 100%;
}
.filter-picker {
margin-left: auto;
}
.filter-panel {
grid-template-columns: 1fr;
}
.library-filter-options,
.filter-actions {
grid-column: auto;
}
.search-field { .search-field {
width: 100%; width: 100%;
} }
@@ -4775,7 +5115,9 @@ textarea:focus {
height: 8rem; height: 8rem;
} }
.piece-thumbnail-placeholder { .piece-thumbnail-placeholder {
display: none; display: grid;
width: 3.25rem;
height: 3.25rem;
} }
.piece-card:last-child { .piece-card:last-child {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -4806,11 +5148,9 @@ textarea:focus {
justify-content: flex-start; justify-content: flex-start;
} }
.piece-actions { .piece-actions {
display: flex; position: absolute;
align-items: center; top: 0.8rem;
flex-wrap: wrap; right: 0.8rem;
justify-content: flex-start;
gap: 0.7rem;
} }
.show-summary { .show-summary {
width: 100%; width: 100%;
+14 -4
View File
@@ -183,6 +183,14 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField <MarkdownEditorField
name="presentation" name="presentation"
label="Öffentlicher Text" label="Öffentlicher Text"
@@ -225,7 +233,7 @@
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if} {#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div> </div>
<label <label
>Titel<input >Kartentitel<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
@@ -262,7 +270,7 @@
</div> </div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p> <p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.presentationTitle || draft.title}</h4> <h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'} {#if draft.presentationMode === 'text'}
<p class="preview-markdown-source"> <p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'} {draft.presentation || 'Noch kein öffentlicher Text.'}
@@ -318,14 +326,16 @@
method="POST" method="POST"
action="?/removeUsage" action="?/removeUsage"
onsubmit={(event) => onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()} !confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
> >
<input type="hidden" name="revision" value={revision} /><input <input type="hidden" name="revision" value={revision} /><input
type="hidden" type="hidden"
name="usageId" name="usageId"
value={usage.id} value={usage.id}
/> />
<button class="text-button danger-text" type="submit" disabled={dirty}>Entfernen</button> <button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>
</form> </form>
</div> </div>
<div class="piece-dialog-primary-action"> <div class="piece-dialog-primary-action">
+42
View File
@@ -12,9 +12,11 @@ import {
getMediaFile, getMediaFile,
getPiece, getPiece,
getTaxonomy, getTaxonomy,
listPieceTemplates,
listPieces, listPieces,
loadPiecePresentationMetadata, loadPiecePresentationMetadata,
savePiece, savePiece,
savePieceAsTemplate,
setPieceArchived, setPieceArchived,
uploadPieceImage, uploadPieceImage,
type PieceInput type PieceInput
@@ -42,6 +44,7 @@ afterEach(() => {
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput { function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return { return {
title: 'Getestetes Piece', title: 'Getestetes Piece',
outputTitle: '',
summary: 'Eine persistente Kurzbeschreibung.', summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk', formatId: 'format-talk',
status: 'ready', status: 'ready',
@@ -73,6 +76,45 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
} }
describe('content management', () => { describe('content management', () => {
it('persists independently versioned templates and copies their complete snapshot', () => {
const sourceId = createPiece('talk');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext'
})
);
const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand');
const template = listPieceTemplates().find((entry) => entry.id === created.id)!;
expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 });
const changed = getPiece(sourceId)!;
savePiece(sourceId, changed.revision, {
...changed,
status: 'ready',
presentationText: 'Spätere Änderung'
});
const copyId = createPiece(created.id);
expect(getPiece(copyId)).toMatchObject({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext',
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]
});
const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1);
expect(updated.revision).toBe(2);
expect(() =>
savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1)
).toThrow('CONFLICT');
});
it('persists complete pieces and rejects stale revisions atomically', () => { it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk'); const id = createPiece('talk');
const initial = getPiece(id)!; const initial = getPiece(id)!;
+126 -70
View File
@@ -23,6 +23,7 @@ export type PieceSourceInput = {
export type PieceInput = { export type PieceInput = {
title: string; title: string;
outputTitle: string;
summary: string; summary: string;
formatId: string | null; formatId: string | null;
status: EditablePieceStatus; status: EditablePieceStatus;
@@ -88,30 +89,12 @@ export type Taxonomy = {
topics: { id: string; name: string }[]; topics: { id: string; name: string }[];
}; };
export const pieceTemplates = [ export type PieceTemplate = {
{ id: string;
id: 'blank', name: string;
name: 'Leer starten', description: string;
description: 'Ein freies Piece ohne Vorgaben.' revision: number;
}, };
{
id: 'talk',
name: 'Gespräch',
description: 'Leitfragen, Recherche und eine klare Überleitung.'
},
{
id: 'find',
name: 'Reaction / Fundstück',
description: 'Quelle, Einordnung und präsentierbare Kernaussage.'
},
{
id: 'interaction',
name: 'Interaktion',
description: 'Quiz, Publikumsfrage oder kleines Spiel.'
}
] as const;
export type PieceTemplateId = (typeof pieceTemplates)[number]['id'];
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([ const ALLOWED_IMAGES = new Map([
@@ -202,6 +185,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return { return {
title, title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'), summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null, formatId: input.formatId || null,
status: input.status, status: input.status,
@@ -251,6 +235,7 @@ export function getTaxonomy(): Taxonomy {
type PieceListRow = { type PieceListRow = {
id: string; id: string;
title: string; title: string;
output_title: string;
summary: string; summary: string;
status: PieceStatus; status: PieceStatus;
revision: number; revision: number;
@@ -341,6 +326,7 @@ export function listPieces(filters: {
type PieceRow = { type PieceRow = {
id: string; id: string;
title: string; title: string;
output_title: string;
summary: string; summary: string;
format_id: string | null; format_id: string | null;
status: PieceStatus; status: PieceStatus;
@@ -371,7 +357,7 @@ export function getPiece(id: string): PieceEditor | null {
const database = getDatabase(); const database = getDatabase();
const row = database const row = database
.prepare( .prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text, `SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, presentation_mode, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
@@ -385,6 +371,7 @@ export function getPiece(id: string): PieceEditor | null {
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
outputTitle: row.output_title,
summary: row.summary, summary: row.summary,
formatId: row.format_id, formatId: row.format_id,
status: row.status, status: row.status,
@@ -484,7 +471,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => { const save = database.transaction(() => {
const result = database const result = database
.prepare( .prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, `UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?, intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
@@ -496,6 +483,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
) )
.run( .run(
input.title, input.title,
input.outputTitle,
input.summary, input.summary,
input.formatId, input.formatId,
input.status, input.status,
@@ -531,50 +519,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
return { revision: expectedRevision + 1, updatedAtUtc }; return { revision: expectedRevision + 1, updatedAtUtc };
} }
function templateDefaults(template: PieceTemplateId): Partial<PieceInput> & { title: string } { function blankPieceInput(): PieceInput {
if (template === 'talk') {
return { return {
title: 'Unbenanntes Gespräch', title: 'Neues Piece',
formatId: 'format-talk', outputTitle: '',
categoryIds: ['cat-jc'],
moderationNotes: '## Leitfragen\n\n- '
};
}
if (template === 'find') {
return {
title: 'Neues Fundstück',
formatId: 'format-find',
categoryIds: ['cat-reaction'],
presentationText: '## Kernaussage\n\n'
};
}
if (template === 'interaction') {
return {
title: 'Neue Interaktion',
formatId: 'format-quiz',
categoryIds: ['cat-game'],
moderationNotes: '## Ablauf\n\n1. '
};
}
return { title: 'Neues Piece' };
}
export function createPiece(template: PieceTemplateId = 'blank'): string {
if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE');
const defaults = templateDefaults(template);
const id = randomUUID();
const now = new Date().toISOString();
const input = validatePieceInput({
title: defaults.title,
summary: '', summary: '',
formatId: defaults.formatId ?? null, formatId: null,
status: 'preparing', status: 'preparing',
durationSeconds: 0, durationSeconds: 0,
intro: '', intro: '',
researchText: '', researchText: '',
moderationNotes: defaults.moderationNotes ?? '', moderationNotes: '',
transitionText: '', transitionText: '',
presentationText: defaults.presentationText ?? '', presentationText: '',
presentationMode: 'text', presentationMode: 'text',
presentationSourcePosition: null, presentationSourcePosition: null,
presentationTitle: '', presentationTitle: '',
@@ -588,20 +545,63 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
presentationImageManual: false, presentationImageManual: false,
presentationMetadataFetchedAtUtc: null, presentationMetadataFetchedAtUtc: null,
topicIds: [], topicIds: [],
categoryIds: defaults.categoryIds ?? [], categoryIds: [],
sources: [] sources: []
}); };
}
function templateInput(templateId: string): PieceInput {
if (templateId === 'blank') return blankPieceInput();
const row = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
.get(templateId) as { snapshot_json: string } | undefined;
if (!row) throw new Error('INVALID_TEMPLATE');
try {
const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch {
throw new Error('INVALID_TEMPLATE');
}
}
export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase()
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[];
return [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.',
revision: 0
},
...rows.filter((template) => {
try {
templateInput(template.id);
return true;
} catch {
return false;
}
})
];
}
export function createPiece(template = 'blank'): string {
const input = templateInput(template);
const id = randomUUID();
const now = new Date().toISOString();
const database = getDatabase(); const database = getDatabase();
database.transaction(() => { database.transaction(() => {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc, research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
input.title, input.title,
input.outputTitle,
input.summary, input.summary,
input.formatId, input.formatId,
input.status, input.status,
@@ -619,13 +619,68 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
return id; return id;
} }
export function savePieceAsTemplate(
pieceId: string,
nameValue: string,
descriptionValue: string,
templateId?: string,
expectedRevision?: number
) {
const piece = getPiece(pieceId);
if (!piece) throw new Error('NOT_FOUND');
const name = cleanText(nameValue, 120, 'template_name');
if (!name) throw new Error('INVALID_TEMPLATE_NAME');
const description = cleanText(descriptionValue, 500, 'template_description');
const snapshot: Partial<typeof piece> = structuredClone(piece);
delete snapshot.id;
delete snapshot.revision;
delete snapshot.createdAtUtc;
delete snapshot.updatedAtUtc;
delete snapshot.media;
snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase();
const now = new Date().toISOString();
if (!templateId) {
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_templates (id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 1, ?, ?)`
)
.run(id, name, description, snapshotJson, now, now);
return { id, revision: 1 };
}
if (!Number.isInteger(expectedRevision) || Number(expectedRevision) < 1) {
throw new Error('INVALID_REVISION');
}
const result = database
.prepare(
`UPDATE piece_templates SET name = ?, description = ?, snapshot_json = ?,
revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?`
)
.run(name, description, snapshotJson, now, templateId, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM piece_templates WHERE id = ?').get(templateId);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
return { id: templateId, revision: Number(expectedRevision) + 1 };
}
export function deletePieceTemplate(templateId: string, expectedRevision: number) {
const result = getDatabase()
.prepare('DELETE FROM piece_templates WHERE id = ? AND revision = ?')
.run(templateId, expectedRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
}
export function captureInbox(titleValue: string, urlValue: string): string { export function captureInbox(titleValue: string, urlValue: string): string {
let title = cleanText(titleValue, 200, 'title'); let title = cleanText(titleValue, 200, 'title');
const url = cleanText(urlValue, 2_000, 'source'); const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES'); if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
if (!title && !url) throw new Error('INVALID_TITLE'); if (!title && !url) throw new Error('INVALID_TITLE');
if (!title) title = new URL(url).hostname; if (!title) title = new URL(url).hostname;
const id = createPiece('blank'); const id = createPiece();
const piece = getPiece(id)!; const piece = getPiece(id)!;
savePiece(id, piece.revision, { savePiece(id, piece.revision, {
...piece, ...piece,
@@ -692,18 +747,19 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => { database.transaction(() => {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, presentation_mode, research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual, presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
`Kopie von ${source.title}`.slice(0, 200), `Kopie von ${source.title}`.slice(0, 200),
source.outputTitle,
source.summary, source.summary,
source.formatId, source.formatId,
source.durationSeconds, source.durationSeconds,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 6, migrations: 8,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+63 -3
View File
@@ -6,13 +6,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js'; import { closeDatabase, getDatabase } from './database.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel,
getPresentationChannelView,
getLiveView, getLiveView,
getPresentationMedia, getPresentationMedia,
getPresentationView, getPresentationView,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
presentationChannelAccess,
presentUsage, presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent, skipCurrent,
startOrResumeLive startOrResumeLive
} from './live.js'; } from './live.js';
@@ -35,6 +40,57 @@ afterEach(() => {
}); });
describe('live mode', () => { describe('live mode', () => {
it('selects any rundown usage by stable id, including the first and completed entries', () => {
let live = getLiveView('show-october');
expect(() =>
selectUsage('show-october', 'usage-effects', 'select-before-start', live.liveRevision)
).toThrow('NOT_STARTED');
startOrResumeLive('show-october', 'start-selection', live.liveRevision);
live = getLiveView('show-october');
selectUsage('show-october', 'usage-effects', 'select-second', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-effects');
selectUsage('show-october', 'usage-console', 'select-first', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-console');
completeCurrent('show-october', 'complete-first', live.liveRevision);
live = getLiveView('show-october');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')?.status
).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console',
status: 'current'
});
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({
revision: 1_000_000_000,
content: null
});
const replacement = rotatePresentationChannelKey('show-october');
expect(replacement).not.toBe(key);
expect(presentationChannelAccess('default', key)).toBe(false);
expect(presentationChannelAccess('default', replacement)).toBe(true);
startOrResumeLive('show-october', 'start-channel', 0);
presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel();
expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull();
});
it('restores live state, records actual intervals and applies commands only once', () => { it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z'); const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started); const initial = getLiveView('show-october', started);
@@ -115,7 +171,8 @@ describe('live mode', () => {
const output = getPresentationView(token)!; const output = getPresentationView(token)!;
expect(output.content).toMatchObject({ expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh', title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }] images: [],
sources: []
}); });
const serialized = JSON.stringify(output); const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.'); expect(serialized).toContain('Weniger Leistung, mehr Idee.');
@@ -125,11 +182,14 @@ describe('live mode', () => {
expect(serialized).not.toContain('presentation.png'); expect(serialized).not.toContain('presentation.png');
const media = await getPresentationMedia(token, 'presentation-image'); const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image'); expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull(); expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october'); const replacementToken = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toBeNull(); expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
expect(getPresentationView(replacementToken)?.content?.title).toBe( expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh' 'Die kleine Konsole mit dem großen Dreh'
); );
+128 -50
View File
@@ -1,6 +1,5 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js'; import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -414,6 +413,34 @@ export function goBack(
); );
} }
export function selectUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'select',
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage( export function presentUsage(
showId: string, showId: string,
usageId: string | null, usageId: string | null,
@@ -480,25 +507,90 @@ export function issuePresentationAccess(showId: string) {
const database = getDatabase(); const database = getDatabase();
const now = nowIso(); const now = nowIso();
ensureLiveState(database, showId, now); ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url'); return database.transaction(() => {
database.transaction(() => { let channel = database
.prepare("SELECT * FROM presentation_channels WHERE id = 'default'")
.get() as { access_key: string; show_id: string | null } | undefined;
if (!channel) {
const accessKey = randomBytes(32).toString('base64url');
clearPresentationState(database, showId);
database database
.prepare( .prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL' `INSERT INTO presentation_channels
(id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc)
VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)`
) )
.run(now, showId); .run(accessKey, hashToken(accessKey), showId, now, now);
channel = { access_key: accessKey, show_id: showId };
} else if (channel.show_id !== showId) {
clearPresentationState(database, showId);
database database
.prepare( .prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc) `UPDATE presentation_channels SET show_id = ?, revision = revision + 1,
VALUES (?, ?, ?, ?)` updated_at_utc = ? WHERE id = 'default'`
) )
.run(randomUUID(), showId, hashToken(token), now); .run(showId, now);
}
return channel.access_key;
})(); })();
return token; }
function clearPresentationState(database: Database.Database, showId: string) {
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = NULL,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ? AND presentation_usage_id IS NOT NULL`
)
.run(nowIso(), showId);
}
export function clearPresentationChannel() {
const now = nowIso();
getDatabase().transaction(() => {
const database = getDatabase();
const channel = database
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
.get() as { show_id: string | null } | undefined;
if (!channel) throw new Error('NOT_FOUND');
if (channel.show_id) clearPresentationState(database, channel.show_id);
database
.prepare(
`UPDATE presentation_channels SET show_id = NULL, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(now);
})();
}
export function rotatePresentationChannelKey(showId: string) {
issuePresentationAccess(showId);
const accessKey = randomBytes(32).toString('base64url');
const now = nowIso();
getDatabase()
.prepare(
`UPDATE presentation_channels SET access_key = ?, key_hash = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(accessKey, hashToken(accessKey), now);
return accessKey;
}
export function presentationChannelAccess(channelId: string, accessKey: string) {
if (!channelId || !accessKey || accessKey.length > 200) return false;
return Boolean(
getDatabase()
.prepare('SELECT 1 FROM presentation_channels WHERE id = ? AND key_hash = ?')
.get(channelId, hashToken(accessKey))
);
} }
function presentationShowId(database: Database.Database, token: string) { function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null; if (!token || token.length > 200) return null;
const channel = database
.prepare('SELECT show_id FROM presentation_channels WHERE key_hash = ?')
.get(hashToken(token)) as { show_id: string | null } | undefined;
if (channel) return channel.show_id;
const row = database const row = database
.prepare( .prepare(
`SELECT show_id FROM presentation_access_tokens `SELECT show_id FROM presentation_access_tokens
@@ -508,15 +600,30 @@ function presentationShowId(database: Database.Database, token: string) {
return row?.show_id ?? null; return row?.show_id ?? null;
} }
export function getPresentationChannelView(channelId: string): PresentationView | null {
const channel = getDatabase()
.prepare('SELECT show_id, revision FROM presentation_channels WHERE id = ?')
.get(channelId) as { show_id: string | null; revision: number } | undefined;
if (!channel) return null;
if (!channel.show_id) return { revision: channel.revision * 1_000_000_000, content: null };
const view = getPresentationViewForShow(channel.show_id);
return { ...view, revision: channel.revision * 1_000_000_000 + view.revision };
}
export function getPresentationView(token: string): PresentationView | null { export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase(); const database = getDatabase();
const showId = presentationShowId(database, token); const showId = presentationShowId(database, token);
if (!showId) return null; if (!showId) return null;
return getPresentationViewForShow(showId);
}
function getPresentationViewForShow(showId: string): PresentationView {
const database = getDatabase();
const state = ensureLiveState(database, showId, nowIso()); const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database const usage = database
.prepare( .prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id, `SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -528,6 +635,7 @@ export function getPresentationView(token: string): PresentationView | null {
.get(state.presentation_usage_id, showId) as .get(state.presentation_usage_id, showId) as
| { | {
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
original_piece_id: string | null; original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode']; presentation_mode_snapshot: PresentationContent['mode'];
@@ -543,33 +651,11 @@ export function getPresentationView(token: string): PresentationView | null {
} }
| undefined; | undefined;
if (!usage) return { revision: state.presentation_revision, content: null }; if (!usage) return { revision: state.presentation_revision, content: null };
const sources =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return { return {
revision: state.presentation_revision, revision: state.presentation_revision,
content: { content: {
mode: usage.presentation_mode_snapshot, mode: usage.presentation_mode_snapshot,
title: usage.title_snapshot, title: usage.output_title_snapshot || usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot), html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot source: usage.presentation_source_url_snapshot
? { ? {
@@ -589,8 +675,8 @@ export function getPresentationView(token: string): PresentationView | null {
usage.presentation_provider_id_snapshot, usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources, sources: [],
images images: []
} }
}; };
} }
@@ -605,19 +691,11 @@ export async function getPresentationPreviewImage(token: string) {
} }
} }
export async function getPresentationMedia(token: string, mediaId: string) { export async function getPresentationMedia(
const database = getDatabase(); token: string,
const showId = presentationShowId(database, token); mediaId: string
if (!showId) return null; ): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
const allowed = database void token;
.prepare( void mediaId;
`SELECT 1 FROM show_live_states l return null;
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
} }
+63 -1
View File
@@ -9,6 +9,7 @@ import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createShow, createShow,
createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
getPieceUsageContexts, getPieceUsageContexts,
getShow, getShow,
@@ -44,6 +45,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
const edited = { ...usage, ...overrides }; const edited = { ...usage, ...overrides };
return { return {
title: edited.title, title: edited.title,
outputTitle: edited.outputTitle,
intro: edited.intro, intro: edited.intro,
research: edited.research, research: edited.research,
moderation: edited.moderation, moderation: edited.moderation,
@@ -63,6 +65,64 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
} }
describe('show builder data layer', () => { describe('show builder data layer', () => {
it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const before = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Direkt geplantes Piece',
templateId: 'talk',
sectionId: section.id
});
const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Darf nicht doppelt entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(repeated).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(1);
expect(getPiece(first.pieceId)).toMatchObject({
title: 'Direkt geplantes Piece',
status: 'ready',
formatId: 'format-talk'
});
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
expect(() =>
createAndAddPieceUsage(created.id, first.revision, 'failing-command', {
title: 'Rollback',
templateId: 'blank',
sectionId: 'missing'
})
).toThrow('NOT_FOUND');
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
const removed = removePieceUsage(created.id, first.usageId, first.revision);
const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', {
title: 'Darf auch nach dem Entfernen nicht neu entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(afterRemoval).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(0);
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
});
it('creates blank and templated shows with independent sections and reserve banks', () => { it('creates blank and templated shows with independent sections and reserve banks', () => {
const templatesBefore = listShowTemplates(); const templatesBefore = listShowTemplates();
expect( expect(
@@ -360,7 +420,8 @@ describe('show builder data layer', () => {
.run('https://youtu.be/dQw4w9WgXcQ'); .run('https://youtu.be/dQw4w9WgXcQ');
getDatabase() getDatabase()
.prepare( .prepare(
`UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0, `UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel',
presentation_mode = 'video', presentation_source_position = 0,
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand', presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube', presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'` presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
@@ -378,6 +439,7 @@ describe('show builder data layer', () => {
}); });
let usage = getShow(show.id)!.sections[0].usages[0]; let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({ expect(usage).toMatchObject({
outputTitle: 'Öffentlicher Ausgabetitel',
presentationMode: 'video', presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ', presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75, presentationSourceTimecodeSeconds: 75,
+76 -10
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createPiece } from './content.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js'; import { detectVideoProvider, type PresentationMode } from './presentation.js';
@@ -42,6 +43,7 @@ export type PieceUsage = {
status: UsageStatus; status: UsageStatus;
durationSeconds: number; durationSeconds: number;
title: string; title: string;
outputTitle: string;
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
@@ -129,6 +131,7 @@ export type PieceUsageInput = {
export type PieceUsageUpdate = { export type PieceUsageUpdate = {
title: string; title: string;
outputTitle: string;
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
@@ -185,6 +188,7 @@ type UsageRow = {
status: UsageStatus; status: UsageStatus;
duration_seconds: number; duration_seconds: number;
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string;
intro_snapshot: string; intro_snapshot: string;
research_snapshot: string; research_snapshot: string;
moderation_snapshot: string; moderation_snapshot: string;
@@ -376,6 +380,7 @@ function usageFromRow(row: UsageRow): PieceUsage {
status: row.status, status: row.status,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
title: row.title_snapshot, title: row.title_snapshot,
outputTitle: row.output_title_snapshot,
intro: row.intro_snapshot, intro: row.intro_snapshot,
research: row.research_snapshot, research: row.research_snapshot,
moderation: row.moderation_snapshot, moderation: row.moderation_snapshot,
@@ -478,7 +483,7 @@ export function getShow(id: string): Show | null {
const usageRows = database const usageRows = database
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot, u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
@@ -805,7 +810,7 @@ export function addPieceUsage(
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null); sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database const piece = database
.prepare( .prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.transition_text, p.presentation_text, p.status, p.presentation_mode, p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
@@ -818,6 +823,7 @@ export function addPieceUsage(
.get(input.pieceId) as .get(input.pieceId) as
| { | {
title: string; title: string;
output_title: string;
duration_seconds: number; duration_seconds: number;
intro: string; intro: string;
research_text: string; research_text: string;
@@ -849,14 +855,14 @@ export function addPieceUsage(
database database
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -865,6 +871,7 @@ export function addPieceUsage(
ids.length, ids.length,
piece.duration_seconds, piece.duration_seconds,
piece.title, piece.title,
piece.output_title,
piece.intro, piece.intro,
piece.research_text, piece.research_text,
piece.moderation_notes, piece.moderation_notes,
@@ -888,6 +895,60 @@ export function addPieceUsage(
})(); })();
} }
export function createAndAddPieceUsage(
showId: string,
expectedRevision: number,
commandId: string,
input: Omit<PieceUsageInput, 'pieceId'> & { title: string; templateId: string }
) {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
const title = cleanText(input.title, 200, 'title', true);
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare(
`SELECT piece_id, usage_id, result_revision FROM planning_commands
WHERE show_id = ? AND command_id = ?`
)
.get(showId, commandId) as
{ piece_id: string; usage_id: string; result_revision: number } | undefined;
if (duplicate) {
return {
pieceId: duplicate.piece_id,
usageId: duplicate.usage_id,
revision: duplicate.result_revision,
duplicate: true
};
}
const pieceId = createPiece(input.templateId || 'blank');
database
.prepare(
`UPDATE pieces SET title = ?, status = 'ready', revision = revision + 1,
updated_at_utc = ? WHERE id = ?`
)
.run(title, new Date().toISOString(), pieceId);
const usage = addPieceUsage(showId, expectedRevision, {
pieceId,
sectionId: input.sectionId,
position: input.position
});
database
.prepare(
`INSERT INTO planning_commands
(show_id, command_id, piece_id, usage_id, result_revision, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(showId, commandId, pieceId, usage.id, usage.revision, new Date().toISOString());
return {
pieceId,
usageId: usage.id,
revision: usage.revision,
duplicate: false
};
})();
}
export function duplicatePieceUsage( export function duplicatePieceUsage(
showId: string, showId: string,
usageId: string, usageId: string,
@@ -899,7 +960,7 @@ export function duplicatePieceUsage(
const sourceSection = usageSection(database, showId, usageId); const sourceSection = usageSection(database, showId, usageId);
const source = database const source = database
.prepare( .prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot, `SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
@@ -922,14 +983,14 @@ export function duplicatePieceUsage(
database database
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -938,6 +999,7 @@ export function duplicatePieceUsage(
ids.length, ids.length,
source.duration_seconds, source.duration_seconds,
source.title_snapshot, source.title_snapshot,
source.output_title_snapshot,
source.intro_snapshot, source.intro_snapshot,
source.research_snapshot, source.research_snapshot,
source.moderation_snapshot, source.moderation_snapshot,
@@ -973,7 +1035,7 @@ export function refreshPieceUsageFromOriginal(
usageSection(database, showId, usageId); usageSection(database, showId, usageId);
const source = database const source = database
.prepare( .prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode, p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
@@ -988,6 +1050,7 @@ export function refreshPieceUsageFromOriginal(
.get(usageId) as .get(usageId) as
| { | {
title: string; title: string;
output_title: string;
duration_seconds: number; duration_seconds: number;
intro: string; intro: string;
research_text: string; research_text: string;
@@ -1009,7 +1072,7 @@ export function refreshPieceUsageFromOriginal(
if (!source) throw new Error('ORIGINAL_NOT_FOUND'); if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
@@ -1020,6 +1083,7 @@ export function refreshPieceUsageFromOriginal(
) )
.run( .run(
source.title, source.title,
source.output_title,
source.duration_seconds, source.duration_seconds,
source.intro, source.intro,
source.research_text, source.research_text,
@@ -1049,6 +1113,7 @@ export function updatePieceUsage(
input: PieceUsageUpdate input: PieceUsageUpdate
): MutationResult { ): MutationResult {
const title = cleanText(input.title, 200, 'title', true); const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!; const duration = validDuration(input.durationSeconds)!;
const values = { const values = {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
@@ -1074,7 +1139,7 @@ export function updatePieceUsage(
usageSection(database, showId, usageId); usageSection(database, showId, usageId);
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
@@ -1085,6 +1150,7 @@ export function updatePieceUsage(
) )
.run( .run(
title, title,
outputTitle,
values.intro, values.intro,
values.research, values.research,
values.moderation, values.moderation,
+15 -5
View File
@@ -3,12 +3,12 @@ import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { import {
createPiece, createPiece,
deletePieceTemplate,
duplicatePiece, duplicatePiece,
getTaxonomy, getTaxonomy,
listPieces, listPieces,
pieceTemplates, listPieceTemplates,
setPieceArchived, setPieceArchived
type PieceTemplateId
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js'; import { getPieceUsageContexts } from '#lib/server/shows.js';
@@ -39,7 +39,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
return { return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
templates: pieceTemplates, templates: listPieceTemplates(),
filters filters
}; };
}; };
@@ -57,12 +57,22 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
let id: string; let id: string;
try { try {
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId); id = createPiece(String(data.get('template') || 'blank'));
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) }); return fail(400, { intent: 'create', error: actionError(cause) });
} }
redirect(303, `/fundus/${id}`); redirect(303, `/fundus/${id}`);
}, },
deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
deletePieceTemplate(String(data.get('templateId') || ''), Number(data.get('revision')));
return { intent: 'deleteTemplate', success: 'Vorlage gelöscht.' };
} catch (cause) {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
}
},
duplicate: async ({ locals, request }) => { duplicate: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+55 -17
View File
@@ -59,15 +59,13 @@
<h1>Fundus</h1> <h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div> </div>
<div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}> <div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{visiblePieces.length}</strong><span>Inhalte</span> <strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div> </div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<details class="template-picker"> <details class="template-picker">
<summary>Neues Piece anlegen</summary> <summary><span aria-hidden="true">+</span> Neues Piece</summary>
<div class="template-popover">
<div class="template-grid"> <div class="template-grid">
{#each data.templates as template (template.id)} {#each data.templates as template (template.id)}
<form method="POST" action="?/create"> <form method="POST" action="?/create">
@@ -78,7 +76,30 @@
</form> </form>
{/each} {/each}
</div> </div>
{#if data.templates.some((template) => template.id !== 'blank')}<details
class="template-management"
>
<summary>Vorlagen verwalten</summary>
{#each data.templates.filter((template) => template.id !== 'blank') as template (template.id)}
<form
method="POST"
action="?/deleteTemplate"
onsubmit={(event) =>
!confirm(`Vorlage „${template.name}“ löschen?`) && event.preventDefault()}
>
<input type="hidden" name="templateId" value={template.id} />
<input type="hidden" name="revision" value={template.revision} />
<span>{template.name}</span>
<button class="text-button danger-text" type="submit">Löschen</button>
</form>
{/each}
</details>{/if}
</div>
</details> </details>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<form <form
id="library-filters" id="library-filters"
@@ -86,6 +107,7 @@
method="GET" method="GET"
aria-label="Fundus durchsuchen und filtern" aria-label="Fundus durchsuchen und filtern"
> >
<div class="fundus-search-row">
<label class="search-field" <label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input ><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
name="q" name="q"
@@ -94,12 +116,17 @@
placeholder="Titel, Quelle oder Thema suchen" placeholder="Titel, Quelle oder Thema suchen"
/></label /></label
> >
<button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker">
<summary><span aria-hidden="true">≡</span> Weitere Filter</summary>
<div class="filter-panel">
<label <label
><span>Status</span><select name="status" ><span>Status</span><select name="status"
><option value="">Alle</option><option ><option value="">Alle</option><option
value="ready" value="ready"
selected={data.filters.status === 'ready'}>Sendefertig</option selected={data.filters.status === 'ready'}>Sendefertig</option
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option ><option value="preparing" selected={data.filters.status === 'preparing'}
>In Arbeit</option
></select ></select
></label ></label
> >
@@ -121,22 +148,19 @@
> >
<label <label
><span>Abschnitt</span><select name="category" ><span>Abschnitt</span><select name="category"
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option ><option value="">Alle</option
>{#each data.taxonomy.categories as item (item.id)}<option
value={item.id} value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select >{/each}</select
></label ></label
> >
<button class="secondary-button" type="submit">Anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</form>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern"> <div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input <input
type="checkbox" type="checkbox"
name="includeUsed" name="includeUsed"
value="1" value="1"
form="library-filters"
checked={showUsedPieces} checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)} onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/> />
@@ -147,13 +171,20 @@
type="checkbox" type="checkbox"
name="includePlanned" name="includePlanned"
value="1" value="1"
form="library-filters"
checked={showPlannedPieces} checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)} onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/> />
<span>Auch eingeplante Inhalte zeigen</span> <span>Auch eingeplante Inhalte zeigen</span>
</label> </label>
</div> </div>
<div class="filter-actions">
<button class="secondary-button" type="submit">Filter anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</div>
</div>
</details>
</div>
</form>
{#if visiblePieces.length} {#if visiblePieces.length}
<div class="piece-grid"> <div class="piece-grid">
@@ -166,9 +197,12 @@
class="piece-thumbnail" class="piece-thumbnail"
src={`/media/${piece.mediaId}`} src={`/media/${piece.mediaId}`}
alt="" alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true" />{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
>S</span <svg viewBox="0 0 24 24" fill="none">
>{/if} <path d="M4.5 6.5h15v11h-15zM7.5 14l3-3 2.2 2.2 1.8-1.7 2.5 2.5" />
<circle cx="15.8" cy="9.2" r="1.2" />
</svg>
</span>{/if}
<div class="piece-primary"> <div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2> <h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p> <p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
@@ -193,8 +227,11 @@
></span ></span
> >
</div> </div>
<div class="piece-actions"> <details class="piece-actions">
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a> <summary aria-label={`Aktionen für ${piece.title}`}
><span aria-hidden="true">•••</span></summary
>
<div class="piece-actions-popover">
<form method="POST" action="?/duplicate"> <form method="POST" action="?/duplicate">
<input type="hidden" name="pieceId" value={piece.id} /><button <input type="hidden" name="pieceId" value={piece.id} /><button
class="text-button" class="text-button"
@@ -209,6 +246,7 @@
/><button class="text-button danger-text" type="submit">Archivieren</button> /><button class="text-button danger-text" type="submit">Archivieren</button>
</form> </form>
</div> </div>
</details>
</article> </article>
{/each} {/each}
</div> </div>
+28 -1
View File
@@ -6,8 +6,10 @@ import {
duplicatePiece, duplicatePiece,
getPiece, getPiece,
getTaxonomy, getTaxonomy,
listPieceTemplates,
loadPiecePresentationMetadata, loadPiecePresentationMetadata,
savePiece, savePiece,
savePieceAsTemplate,
setPieceArchived, setPieceArchived,
uploadPieceImage, uploadPieceImage,
type EditablePieceStatus, type EditablePieceStatus,
@@ -57,6 +59,7 @@ function pieceInput(data: FormData): PieceInput {
} }
return { return {
title: text(data, 'title'), title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
summary: text(data, 'summary'), summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null, formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus, status: text(data, 'status') as EditablePieceStatus,
@@ -120,7 +123,12 @@ export const load: PageServerLoad = ({ locals, params }) => {
const piece = getPiece(params.id); const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden'); if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv'); if (piece.status === 'archived') redirect(303, '/archiv');
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) }; return {
piece,
taxonomy: getTaxonomy(),
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
rendered: renderedFields(piece)
};
}; };
export const actions = { export const actions = {
@@ -161,6 +169,25 @@ export const actions = {
} }
redirect(303, `/fundus/${params.id}`); redirect(303, `/fundus/${params.id}`);
}, },
saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const reference = text(data, 'templateRef');
const [templateId, revision] = reference.split('|');
try {
savePieceAsTemplate(
params.id,
text(data, 'templateName'),
text(data, 'templateDescription'),
templateId || undefined,
revision ? Number(revision) : undefined
);
return { intent: 'saveTemplate', success: 'Vorlage gespeichert.' };
} catch (cause) {
const problem = saveError(cause);
return fail(problem.status, { intent: 'saveTemplate', ...problem });
}
},
upload: async ({ locals, params, request }) => { upload: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+28 -2
View File
@@ -44,7 +44,7 @@
); );
let previewContent = $derived<PresentationContent>({ let previewContent = $derived<PresentationContent>({
mode: draft.presentationMode, mode: draft.presentationMode,
title: draft.title, title: draft.outputTitle || draft.title,
html: rendered.presentation, html: rendered.presentation,
source: featuredSource source: featuredSource
? { ? {
@@ -413,6 +413,14 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField <MarkdownEditorField
name="presentationText" name="presentationText"
label="Öffentlicher Text" label="Öffentlicher Text"
@@ -443,7 +451,7 @@
></label ></label
> >
<label <label
>Titel<input >Kartentitel<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
@@ -565,6 +573,24 @@
{#snippet footer()} {#snippet footer()}
<div class="piece-dialog-secondary-actions"> <div class="piece-dialog-secondary-actions">
<details class="template-save-menu">
<summary>Als Vorlage speichern</summary>
<form method="POST" action="?/saveTemplate">
<label
>Vorlage<select name="templateRef">
<option value="">Neue Vorlage</option>
{#each data.templates as template (template.id)}<option
value={`${template.id}|${template.revision}`}>{template.name}</option
>{/each}
</select></label
>
<label>Name<input name="templateName" required maxlength="120" /></label>
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
<button class="text-button" type="submit" disabled={actionsBlocked}
>Vorlage speichern</button
>
</form>
</details>
<form method="POST" action="?/duplicate"> <form method="POST" action="?/duplicate">
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button> <button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
</form> </form>
@@ -18,7 +18,7 @@ export const load: PageServerLoad = ({ locals, params }) => {
images: { id: string; altText: string }[]; images: { id: string; altText: string }[];
} = { } = {
mode: piece.presentationMode, mode: piece.presentationMode,
title: piece.title, title: piece.outputTitle || piece.title,
html: renderMarkdown(piece.presentationText), html: renderMarkdown(piece.presentationText),
source, source,
previewTitle: piece.presentationTitle, previewTitle: piece.presentationTitle,
+7 -6
View File
@@ -1,9 +1,10 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { getPresentationView } from '#lib/server/live.js'; import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
export const load: PageServerLoad = ({ params }) => { export const load: PageServerLoad = ({ params, cookies }) => {
const view = getPresentationView(params.token); const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!view) error(404, 'Präsentationsausgabe nicht gefunden'); const view = presentationChannelAccess(params.token, accessKey)
return { view }; ? getPresentationChannelView(params.token)
: null;
return { view, channelId: params.token };
}; };
+20 -4
View File
@@ -5,8 +5,10 @@
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let view = $state(untrack(() => data.view)); let view = $state(untrack(() => data.view ?? { revision: -1, content: null }));
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected'); let connection = $state<'connected' | 'waiting' | 'interrupted'>(
untrack(() => (data.view ? 'connected' : 'waiting'))
);
onMount(() => { onMount(() => {
let stopped = false; let stopped = false;
@@ -26,8 +28,22 @@
} }
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000); if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
} }
connection = 'waiting'; async function connect() {
timer = setTimeout(refresh, 500); const accessKey = decodeURIComponent(location.hash.slice(1));
if (accessKey) {
const response = await fetch(`/heinzlive/${data.channelId}/access`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: accessKey })
});
if (!response.ok) {
connection = 'interrupted';
return;
}
}
timer = setTimeout(refresh, 0);
}
void connect();
return () => { return () => {
stopped = true; stopped = true;
clearTimeout(timer); clearTimeout(timer);
@@ -0,0 +1,18 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presentationChannelAccess } from '#lib/server/live.js';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const body = (await request.json().catch(() => null)) as { key?: unknown } | null;
const accessKey = typeof body?.key === 'string' ? body.key : '';
if (!presentationChannelAccess(params.token, accessKey)) {
error(404, 'Präsentationsausgabe nicht gefunden');
}
cookies.set(`sendewerk-output-${params.token}`, accessKey, {
path: `/heinzlive/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: url.protocol === 'https:'
});
return json({ success: true }, { headers: { 'Cache-Control': 'no-store' } });
};
@@ -1,11 +1,13 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getPresentationMedia } from '#lib/server/live.js'; import { getPresentationMedia, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params, cookies }) => {
const media = await getPresentationMedia(params.token, params.id); const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey)) error(404, 'Bild nicht gefunden');
const media = await getPresentationMedia(accessKey, params.id);
if (!media) error(404, 'Bild nicht gefunden'); if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, { return new Response(new Uint8Array(media.data), {
headers: { headers: {
'Content-Type': media.mimeType, 'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize), 'Content-Length': String(media.byteSize),
@@ -1,9 +1,12 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getPresentationPreviewImage } from '#lib/server/live.js'; import { getPresentationPreviewImage, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params, cookies }) => {
const image = await getPresentationPreviewImage(params.token); const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey))
error(404, 'Vorschaubild nicht verfügbar');
const image = await getPresentationPreviewImage(accessKey);
if (!image) error(404, 'Vorschaubild nicht verfügbar'); if (!image) error(404, 'Vorschaubild nicht verfügbar');
return new Response(new Uint8Array(image.data), { return new Response(new Uint8Array(image.data), {
headers: { headers: {
@@ -1,9 +1,12 @@
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getPresentationView } from '#lib/server/live.js'; import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = ({ params }) => { export const GET: RequestHandler = ({ params, cookies }) => {
const view = getPresentationView(params.token); const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey))
error(404, 'Präsentationsausgabe nicht gefunden');
const view = getPresentationChannelView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden'); if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } }); return json(view, { headers: { 'Cache-Control': 'no-store' } });
}; };
+33 -1
View File
@@ -4,11 +4,14 @@ import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel,
getLiveView, getLiveView,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
presentUsage, presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent, skipCurrent,
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } from '#lib/server/live.js';
@@ -26,6 +29,8 @@ function actionError(cause: unknown) {
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.' error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
}; };
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' }; if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NOT_STARTED')
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' }; if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED') if (code === 'LIVE_ORDER_LOCKED')
return { return {
@@ -91,6 +96,18 @@ export const actions = {
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
); );
}, },
select: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('select', () =>
selectUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => { present: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -118,10 +135,25 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
try { try {
const token = issuePresentationAccess(text(data, 'showId')); const token = issuePresentationAccess(text(data, 'showId'));
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/${token}` }; return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
} catch (cause) { } catch (cause) {
const problem = actionError(cause); const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem }); return fail(problem.status, { intent: 'createOutput', ...problem });
} }
},
rotateOutput: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const token = rotatePresentationChannelKey(text(data, 'showId'));
return { intent: 'rotateOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'rotateOutput', ...problem });
}
},
clearOutput: async ({ locals }) => {
requireUser(locals.user);
return attempt('clearOutput', () => clearPresentationChannel());
} }
} satisfies Actions; } satisfies Actions;
+36 -1
View File
@@ -161,8 +161,20 @@
</form> </form>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}> <form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Präsentationsfenster öffnen</button> <button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form> </form>
<details class="live-output-settings">
<summary>Ausgabekanal</summary>
<div>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel widerrufen und erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal leeren</button>
</form>
</div>
</details>
<div class="live-session-summary"> <div class="live-session-summary">
<strong>{data.live.completedCount} / {data.live.totalCount}</strong> <strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span> <span>{progress}% bearbeitet</span>
@@ -221,6 +233,17 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </form>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -288,6 +311,18 @@
<strong>{usage.title}</strong><small>{usage.status}</small> <strong>{usage.title}</strong><small>{usage.status}</small>
</div> </div>
<div class="live-run-actions"> <div class="live-run-actions">
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="text-button"
type="submit"
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
aria-label={`${usage.title} als aktuelles Piece auswählen`}>Aktuell</button
>
</form>
{#if usage.status === 'open'} {#if usage.status === 'open'}
<form <form
method="POST" method="POST"
+17 -1
View File
@@ -1,10 +1,12 @@
import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { getTaxonomy, listPieces } from '#lib/server/content.js'; import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import { import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
getShow, getShow,
getPieceUsageContexts, getPieceUsageContexts,
@@ -123,6 +125,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
startsAtLocal: berlinInput(show.startsAtUtc), startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
pieceTemplates: listPieceTemplates(),
createCommandId: randomUUID(),
filters: { filters: {
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '', categoryId: url.searchParams.get('category') ?? '',
@@ -197,12 +201,24 @@ export const actions = {
}) })
); );
}, },
createUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('createUsage', () =>
createAndAddPieceUsage(params.id, Number(data.get('revision')), text(data, 'commandId'), {
title: text(data, 'title'),
templateId: text(data, 'templateId') || 'blank',
sectionId: text(data, 'sectionId') || null
})
);
},
updateUsage: async ({ locals, params, request }) => { updateUsage: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
return attempt('updateUsage', () => return attempt('updateUsage', () =>
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), { updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
title: text(data, 'title'), title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
durationSeconds: pieceDurationInSeconds(data), durationSeconds: pieceDurationInSeconds(data),
ownNotes: text(data, 'ownNotes'), ownNotes: text(data, 'ownNotes'),
intro: text(data, 'intro'), intro: text(data, 'intro'),
+68 -2
View File
@@ -22,9 +22,11 @@
let moveSectionForm: HTMLFormElement; let moveSectionForm: HTMLFormElement;
let moveUsageForm: HTMLFormElement; let moveUsageForm: HTMLFormElement;
let addUsageForm: HTMLFormElement; let addUsageForm: HTMLFormElement;
let removeUsageForm: HTMLFormElement;
let draggedSectionId = $state(''); let draggedSectionId = $state('');
let draggedUsageId = $state(''); let draggedUsageId = $state('');
let draggedPieceId = $state(''); let draggedPieceId = $state('');
let removedUsageId = $state('');
let dropSectionId = $state(''); let dropSectionId = $state('');
let dropPosition = $state(0); let dropPosition = $state(0);
let copyMessage = $state(''); let copyMessage = $state('');
@@ -165,6 +167,17 @@
setTimeout(() => moveUsageForm.requestSubmit()); setTimeout(() => moveUsageForm.requestSubmit());
} }
function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) {
finishDrag();
return;
}
removedUsageId = dragged.id;
finishDrag();
setTimeout(() => removeUsageForm.requestSubmit());
}
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => { const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
const affected = formElement.closest<HTMLElement>( const affected = formElement.closest<HTMLElement>(
'.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form' '.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form'
@@ -287,8 +300,18 @@
<div class="builder-workspace"> <div class="builder-workspace">
<section <section
class="builder-pane source-pane" class="builder-pane source-pane"
class:drop-ready={dropTarget === 'source'}
class:mobile-hidden={mobilePane !== 'fundus'} class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title" aria-labelledby="source-title"
ondragover={(event) => {
if (dragged?.kind !== 'usage') return;
event.preventDefault();
dropTarget = 'source';
}}
ondrop={(event) => {
event.preventDefault();
removeDraggedUsage();
}}
> >
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
@@ -297,6 +320,33 @@
</div> </div>
<span>{visiblePieces.length}</span> <span>{visiblePieces.length}</span>
</header> </header>
{#if dropTarget === 'source'}<div class="drag-remove-target">
<strong>Aus Sendung entfernen</strong><span>Das Fundus-Original bleibt erhalten.</span>
</div>{/if}
<details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary>
<form use:enhance={enhanceCommand} method="POST" action="?/createUsage">
<input type="hidden" name="revision" value={data.show.revision} />
<input type="hidden" name="commandId" value={data.createCommandId} />
<label>Titel<input name="title" required maxlength="200" /></label>
<label
>Vorlage<select name="templateId">
{#each data.pieceTemplates as template (template.id)}<option value={template.id}
>{template.name}</option
>{/each}
</select></label
>
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}
</select></label
>
<button class="secondary-button" type="submit">Anlegen und einplanen</button>
</form>
</details>
<form id="builder-filters" method="GET" class="builder-filter"> <form id="builder-filters" method="GET" class="builder-filter">
<label <label
><span class="sr-only">Fundus durchsuchen</span><input ><span class="sr-only">Fundus durchsuchen</span><input
@@ -770,7 +820,13 @@
></label ></label
><button class="secondary-button" type="submit">Einplanen</button> ><button class="secondary-button" type="submit">Einplanen</button>
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/removeUsage"> <form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input <input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden" type="hidden"
name="usageId" name="usageId"
@@ -778,7 +834,7 @@
/><button /><button
class="text-button danger-text" class="text-button danger-text"
type="submit" type="submit"
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button aria-label={`${usage.title} aus Sendung entfernen`}>Aus Sendung entfernen</button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
@@ -847,4 +903,14 @@
value={draggedPieceId} value={draggedPieceId}
/><input type="hidden" name="sectionId" value={dropSectionId} /> /><input type="hidden" name="sectionId" value={dropSectionId} />
</form> </form>
<form
bind:this={removeUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} />
<input type="hidden" name="usageId" value={removedUsageId} />
</form>
</div> </div>
+93 -68
View File
@@ -306,8 +306,14 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung'); await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
const firstRow = await page.locator('.piece-card').nth(0).boundingBox(); const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox(); const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
@@ -319,11 +325,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
expect(searchBox?.height).toBe(statusBox?.height); expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart'); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Anwenden' }).click(); await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Status').selectOption('preparing'); await page.getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Anwenden' }).click(); await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByRole('button', { name: 'Zurücksetzen' }).click(); await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
@@ -346,7 +358,13 @@ test('sent and planned source filters stay independent and removing the last usa
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0); ).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable')); await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch verwendete Inhalte zeigen').check(); await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await expect( await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0); ).toHaveCount(0);
@@ -365,7 +383,7 @@ test('sent and planned source filters stay independent and removing the last usa
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2); await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable'); await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte'); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte');
await page.getByRole('button', { name: 'Anwenden' }).click(); await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked(); await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked();
await expect(usedPiece).toBeVisible(); await expect(usedPiece).toBeVisible();
await page.screenshot({ await page.screenshot({
@@ -374,7 +392,7 @@ test('sent and planned source filters stay independent and removing the last usa
}); });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Filterprüfung'); await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
@@ -426,9 +444,10 @@ test('sent and planned source filters stay independent and removing the last usa
fullPage: true fullPage: true
}); });
page.once('dialog', (dialog) => dialog.accept());
await page await page
.locator('.reserve-usage') .locator('.reserve-usage')
.getByRole('button', { name: /aus Reserve entfernen/ }) .getByRole('button', { name: /aus Sendung entfernen/ })
.click(); .click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck(); await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect( await expect(
@@ -521,9 +540,10 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
test('a show can be planned from a template with independent usages and reserve', async ({ test('a show can be planned from a template with independent usages and reserve', async ({
page page
}, testInfo) => { }, testInfo) => {
test.slow();
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Planung Spiele und JC'); await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
@@ -584,16 +604,9 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie'); await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
const desktopEditorItems = await page await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
.locator('dialog[open] .usage-dialog-editor') await expectNoHorizontalOverflow(page);
.evaluate((element) =>
Array.from(element.children)
.filter((child) => child.matches('section, button'))
.map((child) => child.getBoundingClientRect().top)
);
expect(Math.abs(desktopEditorItems[0] - desktopEditorItems[1])).toBeLessThan(2);
expect(desktopEditorItems[2]).toBeGreaterThan(desktopEditorItems[1]);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('verwendungseditor-desktop.png'), path: testInfo.outputPath('verwendungseditor-desktop.png'),
fullPage: true fullPage: true
@@ -613,9 +626,14 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt'); await usages.nth(0).getByLabel('Titel', { exact: true }).fill('Konsole als Auftakt');
await usages.nth(0).locator('.piece-meta-details > summary').click();
await usages.nth(0).getByLabel('Dauer in Minuten').fill('5'); await usages.nth(0).getByLabel('Dauer in Minuten').fill('5');
await usages.nth(0).getByLabel('Eigene Notizen').fill('Kurbel zuerst zeigen'); await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click();
await usages
.nth(0)
.getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true })
.fill('Kurbel zuerst zeigen');
await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click(); await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt'); await expect(reactionSection).toContainText('Konsole als Auftakt');
@@ -629,9 +647,14 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick'); await usages.nth(1).getByLabel('Titel', { exact: true }).fill('Konsole als Rückblick');
await usages.nth(1).locator('.piece-meta-details > summary').click();
await usages.nth(1).getByLabel('Dauer in Minuten').fill('15'); await usages.nth(1).getByLabel('Dauer in Minuten').fill('15');
await usages.nth(1).getByLabel('Eigene Notizen').fill('Andere Perspektive'); await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click();
await usages
.nth(1)
.getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true })
.fill('Andere Perspektive');
await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click(); await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.'); await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
@@ -661,7 +684,7 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie'); await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect( await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' }) page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible(); ).toBeVisible();
@@ -689,7 +712,6 @@ test('a show can be planned from a template with independent usages and reserve'
); );
await expect(reserveUsage).toHaveClass(/is-dragging/); await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer }); await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveHandle.dispatchEvent('dragend', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose(); await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0); await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable'); await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
@@ -699,7 +721,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click(); .click();
await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen'); await expect(savedUsage.locator('textarea[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const duplicatedUsage = page const duplicatedUsage = page
.locator('.editable-usage') .locator('.editable-usage')
@@ -710,7 +732,7 @@ test('a show can be planned from a template with independent usages and reserve'
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click(); .click();
await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15'); await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15');
await expect(duplicatedUsage.nth(1).getByLabel('Eigene Notizen')).toHaveValue( await expect(duplicatedUsage.nth(1).locator('textarea[name="ownNotes"]')).toHaveValue(
'Andere Perspektive' 'Andere Perspektive'
); );
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
@@ -719,15 +741,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click(); .click();
const mobileEditorItems = await page await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
.locator('dialog[open] .usage-dialog-editor')
.evaluate((element) =>
Array.from(element.children)
.filter((child) => child.matches('section, button'))
.map((child) => child.getBoundingClientRect().top)
);
expect(mobileEditorItems[1]).toBeGreaterThan(mobileEditorItems[0]);
expect(mobileEditorItems[2]).toBeGreaterThan(mobileEditorItems[1]);
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('verwendungseditor-mobile.png'), path: testInfo.outputPath('verwendungseditor-mobile.png'),
@@ -735,18 +749,16 @@ test('a show can be planned from a template with independent usages and reserve'
}); });
await page await page
.locator('dialog[open]') .locator('dialog[open]')
.getByRole('button', { name: 'Entfernen' }) .getByRole('button', { name: 'Aus Sendung entfernen' })
.scrollIntoViewIfNeeded(); .scrollIntoViewIfNeeded();
await expect( await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Entfernen' }) page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' })
).toBeVisible(); ).toBeVisible();
await page await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]') .locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
.evaluate((element) => ((element as HTMLInputElement).value = '1')); .evaluate((element) => ((element as HTMLInputElement).value = '1'));
await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('dialog[open]').getByRole('alert')).toContainText( await expect(page.getByRole('alert')).toContainText('inzwischen geändert');
'inzwischen geändert'
);
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
await page.getByRole('button', { name: 'Ablauf', exact: true }).click(); await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel'); await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
@@ -863,9 +875,8 @@ test('validated YouTube and TikTok players switch without exposing private field
); );
await page.goto('/live?show=e2e-presentation'); await page.goto('/live?show=e2e-presentation');
const popupPromise = page.waitForEvent('popup'); const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
const output = await popupPromise; const output = await popupPromise;
await output.waitForLoadState('networkidle');
await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible(); await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible();
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click(); await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
@@ -910,6 +921,7 @@ test('live control persists state and exposes only explicit presentation data',
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click(); await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
const png = Buffer.from( const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
@@ -944,9 +956,9 @@ test('live control persists state and exposes only explicit presentation data',
const [popup] = await Promise.all([ const [popup] = await Promise.all([
page.waitForEvent('popup'), page.waitForEvent('popup'),
page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click() page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
]); ]);
await popup.waitForURL(/\/heinzlive\/[A-Za-z0-9_-]+$/); await popup.waitForURL(/\/heinzlive\/default#/);
const outputUrl = popup.url(); const outputUrl = popup.url();
const anonymous = await browser.newContext(); const anonymous = await browser.newContext();
const output = await anonymous.newPage(); const output = await anonymous.newPage();
@@ -962,14 +974,17 @@ test('live control persists state and exposes only explicit presentation data',
timeout: 10_000 timeout: 10_000
}); });
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible(); await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible(); await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0);
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true }); await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
const outputHtml = await output.content(); const outputHtml = await output.content();
expect(outputHtml).not.toContain('Herstellerangaben'); expect(outputHtml).not.toContain('Herstellerangaben');
expect(outputHtml).not.toContain('Erst Formfaktor zeigen'); expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
expect(outputHtml).not.toContain(adminEmail); expect(outputHtml).not.toContain(adminEmail);
expect(outputHtml).not.toContain('live-konsole.png'); expect(outputHtml).not.toContain('live-konsole.png');
const outputState = await anonymous.request.get(`${outputUrl}/state`); const stateUrl = new URL(outputUrl);
stateUrl.hash = '';
stateUrl.pathname += '/state';
const outputState = await anonymous.request.get(stateUrl.toString());
expect(outputState.status()).toBe(200); expect(outputState.status()).toBe(200);
const outputJson = await outputState.text(); const outputJson = await outputState.text();
expect(outputJson).toContain('Weniger Leistung'); expect(outputJson).toContain('Weniger Leistung');
@@ -999,9 +1014,11 @@ test('live control persists state and exposes only explicit presentation data',
await page.evaluate(() => { await page.evaluate(() => {
window.open = () => null; window.open = () => null;
}); });
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await expect( await expect(
page.getByText('Popup blockiert. Bitte Popups erlauben und erneut öffnen.') page
.getByRole('status')
.filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' })
).toBeVisible(); ).toBeVisible();
await anonymous.close(); await anonymous.close();
await popup.close(); await popup.close();
@@ -1014,13 +1031,14 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
}, testInfo) => { }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 }); await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus'); await page.goto('/fundus');
await page.getByText('Neues Piece anlegen').click(); await page.locator('.template-picker > summary').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click(); await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url(); const pieceUrl = page.url();
await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown'); await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück mit Markdown');
await page.getByRole('link', { name: '← Fundus', exact: true }).click(); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl); await expect(page).toHaveURL(pieceUrl);
@@ -1032,16 +1050,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await page
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte'); await openEditorialField(page, 'Moderationsstichpunkte');
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.'); await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Den Kern in zwei Sätzen erklären.');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue( await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );
await openEditorialField(page, 'Präsentationstext'); await openEditorialField(page, 'Präsentationstext');
await page await page
.getByLabel('Öffentlicher Text') .getByRole('textbox', { name: 'Öffentlicher Text', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); .fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
@@ -1056,11 +1078,11 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0); await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau'); await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
await page.reload(); await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); await expect(page.getByLabel('Titel', { exact: true })).toHaveValue('E2E Fundstück mit Markdown');
await page.getByText('Status, Dauer und Einordnung').click(); await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue( await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );
@@ -1088,7 +1110,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
const concurrent = await page.context().newPage(); const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl); await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert'); await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByText('Status, Dauer und Einordnung').click(); await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent await concurrent
@@ -1098,7 +1120,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
timeout: 10_000 timeout: 10_000
}); });
await concurrent.reload(); await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); await expect(concurrent.getByLabel('Titel', { exact: true })).toHaveValue(
'E2E Fundstück, extern aktualisiert'
);
await concurrent.getByText('Status, Dauer und Einordnung').click(); await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.' 'Diese veraltete Änderung darf nicht gewinnen.'
@@ -1110,7 +1134,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect( await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible(); ).toBeVisible();
await page.getByRole('link', { name: 'Bearbeiten' }).click(); await page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }).click();
await page.getByRole('button', { name: 'Piece archivieren' }).click(); await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/); await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' }); const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
@@ -1119,18 +1143,19 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(archived).toHaveCount(0); await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert'); await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card .piece-actions > summary').click();
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click(); await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(page.getByLabel('Titel')).toHaveValue( await expect(page.getByLabel('Titel', { exact: true })).toHaveValue(
'Kopie von E2E Fundstück, extern aktualisiert' 'Kopie von E2E Fundstück, extern aktualisiert'
); );
await page.context().setOffline(true); await page.context().setOffline(true);
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung'); await page.getByLabel('Titel', { exact: true }).fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', { await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000 timeout: 10_000
}); });
await page.context().setOffline(false); await page.context().setOffline(false);
await page.getByRole('button', { name: 'Jetzt speichern' }).click(); await page.getByRole('button', { name: 'Erneut speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -1168,15 +1193,15 @@ test('a quickly captured video can be written and previewed before classificatio
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page await page
.getByLabel('Private Recherche') .getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Warum funktioniert der Einstieg so unmittelbar?'); .fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte'); await openEditorialField(page, 'Moderationsstichpunkte');
await page await page
.getByLabel('Moderationsstichpunkte') .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.'); .fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Video').check(); await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
@@ -1191,9 +1216,9 @@ test('a quickly captured video can be written and previewed before classificatio
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Video')).toBeVisible(); await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
@@ -1202,14 +1227,14 @@ test('link preview uses one existing source, persists manual fields and isolates
}, testInfo) => { }, testInfo) => {
await page.goto('/fundus/piece-console'); await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Linkvorschau').check(); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Linkvorschau')).toBeChecked(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue( await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau' 'Manuell korrigierte Linkvorschau'
); );