2 Commits
Author SHA1 Message Date
hnzio 25da870601 Release 0.5.2 2026-10-22 13:51:30 +02:00
hnzio fb24d171ae Release 0.5.1 2026-10-22 07:56:25 +02:00
38 changed files with 6286 additions and 440 deletions

No files matched your search

+46 -13
View File
@@ -77,12 +77,27 @@ automatisch; ein sichtbarer Status meldet Erfolg, Fehler oder eine parallele
Änderung. In diesem Fall muss die Seite neu geladen werden, bevor erneut Änderung. In diesem Fall muss die Seite neu geladen werden, bevor erneut
gespeichert wird. gespeichert wird.
Bereits gesendete Pieces und Pieces mit einer Einplanung in einer noch nicht
abgeschlossenen Sendung sind standardmäßig ausgeblendet. Als gesendet gilt nur
eine erledigte Verwendung in einer abgeschlossenen Sendung; übersprungene oder
reservierte Inhalte gelten nicht als gesendet. Über **Auch verwendete Inhalte
zeigen** und **Auch eingeplante Inhalte zeigen** lassen sich beide Gruppen
unabhängig einblenden. Dann erscheinen auch Datum, Sendung und die aufklappbare
Verwendungshistorie.
Markdown ist in Einleitung, Recherche, Moderation, Überleitung und Präsentation Markdown ist in Einleitung, Recherche, Moderation, Überleitung und Präsentation
möglich. Die Ausgabe wird serverseitig auf eine begrenzte Auswahl sicherer möglich. Die Ausgabe wird serverseitig auf eine begrenzte Auswahl sicherer
Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müssen Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müssen
als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten
Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar. Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar.
Für die Präsentation kann ein Piece alternativ genau eine seiner vorhandenen
Quellen als Linkkarte oder Video verwenden. Linktitel, Anreißer und Bildadresse
lassen sich manuell korrigieren. **Vorschau laden** ruft Metadaten ausdrücklich
neu ab; manuell geänderte Felder werden dabei nicht überschrieben. Die
**Präsentation testen**-Ansicht öffnet den aktuellen Piece-Inhalt isoliert und
verändert weder eine Sendung noch deren laufende Ausgabe.
## Sendungen planen ## Sendungen planen
Unter **Sendungen** lässt sich ein neuer Stream leer, mit dem Standardablauf Unter **Sendungen** lässt sich ein neuer Stream leer, mit dem Standardablauf
@@ -92,11 +107,21 @@ eingeplanten Pieces. Die Reservebank zählt nie zur Planzeit.
Der sendefertige Fundus steht neben dem Ablauf. Pieces können mehrfach Der sendefertige Fundus steht neben dem Ablauf. Pieces können mehrfach
eingeplant und je Verwendung mit eigenem Titel, eigener Dauer und eigenen eingeplant und je Verwendung mit eigenem Titel, eigener Dauer und eigenen
Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Abschnitte Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Das
und Verwendungen lassen sich per Drag-and-drop sowie gleichwertig mit sichtbaren Stift-Symbol öffnet dafür einen auch mobil scrollbaren Editor-Dialog. **Aus
Schaltflächen und Auswahlfeldern verschieben. Twitch-Titel und Ankündigung sind Fundus aktualisieren** übernimmt auf Wunsch Titel, Dauer und Redaktionstexte des
kopierbar. Gleichzeitige Änderungen werden über die Revision der gesamten aktuellen Originals; eigene Notizen, Live-Status und gemessene Zeiten bleiben
Sendung erkannt und nicht still überschrieben. erhalten. Abschnitte und Verwendungen lassen sich per Drag-and-drop sowie
gleichwertig mit sichtbaren Schaltflächen und Auswahlfeldern verschieben.
Twitch-Titel und Ankündigung sind kopierbar. Gleichzeitige Änderungen werden
über die Revision der gesamten Sendung erkannt und nicht still überschrieben.
Die Quellspalte blendet gesendete und bereits in derselben Sendung eingeplante
Originale zunächst aus; beide Gruppen lassen sich unabhängig wieder anzeigen.
Eine konkrete Einplanung kann direkt hinter sich dupliziert werden. Dabei
bleiben ihre angepassten Texte und ihre Dauer erhalten, während Live-Status und
gemessene Zeiten neu beginnen. **Piece duplizieren** im Fundus erzeugt dagegen
weiterhin einen eigenständigen Fundusinhalt.
## Live moderieren und präsentieren ## Live moderieren und präsentieren
@@ -113,11 +138,18 @@ sortieren beziehungsweise als nächster Beitrag einfügen.
Das separate Präsentationsfenster unter `/heinzlive/...` verwendet einen langen, Das separate Präsentationsfenster unter `/heinzlive/...` verwendet einen langen,
widerrufbaren Zufallstoken. Es ändert sich ausschließlich durch die Aktion widerrufbaren Zufallstoken. Es ändert sich ausschließlich durch die Aktion
**Präsentieren** und erhält nur Präsentationstext, öffentliche Quellen und die **Präsentieren** und erhält nur den bei der Einplanung gespeicherten Text-, Link-
zum präsentierten Piece gehörenden Bilder. Recherche, Moderationsnotizen, oder Video-Snapshot. YouTube- und TikTok-Adressen werden auf validierte IDs
eigene Notizen, Kontodaten und interne Dateipfade werden weder im HTML noch im reduziert und ohne Autoplay in offiziellen Player-Iframes dargestellt. Andere
JSON-Zustand ausgeliefert. Der Token ist ein Zugangsgeheimnis und darf nicht oder unvollständige Videoquellen erscheinen als Linkkarte. Recherche,
weitergegeben oder protokolliert werden. Moderationsnotizen, eigene Notizen, Kontodaten und interne Dateipfade werden
weder im HTML noch im JSON-Zustand ausgeliefert. Der Token ist ein
Zugangsgeheimnis und darf nicht weitergegeben oder protokolliert werden.
Metadaten und externe Vorschaubilder werden ausschließlich serverseitig mit
DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale
Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
werden nicht übernommen.
## Befehle ## Befehle
@@ -194,6 +226,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind `better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und
MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum. Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
installierten Baum.
+69 -2
View File
@@ -132,19 +132,86 @@
| Getrennte sichere Ausgabe | anonymer Browserkontext prüft Text, Bild, HTML, JSON und fremde Medien ohne private Daten | erfüllt am 22.10.2026 | | Getrennte sichere Ausgabe | anonymer Browserkontext prüft Text, Bild, HTML, JSON und fremde Medien ohne private Daten | erfüllt am 22.10.2026 |
| Reserve und Reihenfolge | Reserve als nächstes Piece eingefügt; offene Reihenfolge und serverseitige Live-Grenze geprüft | erfüllt am 22.10.2026 | | Reserve und Reihenfolge | Reserve als nächstes Piece eingefügt; offene Reihenfolge und serverseitige Live-Grenze geprüft | erfüllt am 22.10.2026 |
| Popup- und Verbindungsfehler sichtbar | blockiertes Popup sowie unterbrochenes und wiederhergestelltes State-Polling im Browser geprüft | erfüllt am 22.10.2026 | | Popup- und Verbindungsfehler sichtbar | blockiertes Popup sowie unterbrochenes und wiederhergestelltes State-Polling im Browser geprüft | erfüllt am 22.10.2026 |
| Persistente Linkvorschau | Quellwahl, manuelle Felder, Reload, fehlendes Bild und isolierte Testansicht im Browser geprüft | erfüllt am 22.10.2026 |
| Sichere Metadatenabrufe | Tests sperren private IPv4-/IPv6-Ziele; Redirect-, Größen- und Zeitlimits sind implementiert | erfüllt am 22.10.2026 |
| Validierte Videoplayer | YouTube-URL-Varianten, TikTok-ID, Zeitmarke und Playerwechsel automatisiert geprüft | erfüllt am 22.10.2026 |
| Präsentationssnapshot | Einplanung, Duplikation und explizite Aktualisierung aus dem Original im Servicetest geprüft | erfüllt am 22.10.2026 |
| Reale OBS-Browserquelle | lokale tokenisierte Browserausgabe vorhanden; kein OBS auf der Prüfmaschine | offen | | Reale OBS-Browserquelle | lokale tokenisierte Browserausgabe vorhanden; kein OBS auf der Prüfmaschine | offen |
| Reale Anbieterwiedergabe | automatisierte Tests fangen YouTube-/TikTok-Requests lokal ab | offen |
## Ausgeführte Prüfungen Etappe 5 ## Ausgeführte Prüfungen Etappe 5
- `npm run lint`: erfolgreich - `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen - `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 20 Tests in 5 Dateien erfolgreich - `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` - `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich - `npm run test:e2e`: 14 von 14 Browsertests erfolgreich
- Anonymer zweiter Chromium-Kontext mit Präsentationstext, Quelle, geschütztem - Anonymer zweiter Chromium-Kontext mit Präsentationstext, Quelle, geschütztem
Bild und gefiltertem JSON geprüft Bild und gefiltertem JSON geprüft
- Mobile Aufnahme der Live-Steuerung bei 390 × 844 Pixeln und Desktopaufnahme - Mobile Aufnahme der Live-Steuerung bei 390 × 844 Pixeln und Desktopaufnahme
der heinzlive-Ausgabe visuell kontrolliert der heinzlive-Ausgabe visuell kontrolliert
- Popup-Blockade sowie Verbindungsabbruch und automatische Wiederverbindung im - Popup-Blockade sowie Verbindungsabbruch und automatische Wiederverbindung im
Browser geprüft Browser geprüft
- YouTube-/TikTok-Iframes, Playerwechsel, mobile Hochformatausgabe,
Linkkartenvorschau ohne Bild, isolierte Testansicht und private Datenfilterung
mit lokal abgefangenen Anbieterrequests geprüft
- SSRF-Blockade einer privaten Zieladresse über die sichtbare Editorfehlermeldung
geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Gezielte Fundus- und Einplanungsanpassung
| Kriterium | Nachweis | Stand |
| ------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------- |
| Gesendet korrekt abgeleitet | Servicetest schließt offene, übersprungene, Reserve- und nicht abgeschlossene Nutzung aus | erfüllt am 22.10.2026 |
| Historie und Fundusfilter | Browser prüft letztes Datum, Sendungslink, weitere Verwendungen und Sofortfilter | erfüllt am 22.10.2026 |
| Unabhängige Quellfilter | Browser kombiniert verwendet/eingeplant getrennt und erhält sie über Suchfilter | erfüllt am 22.10.2026 |
| Reserve und Mehrfacheinplanung | Reserve zählt als eingeplant; bewusstes Wiedereinblenden erlaubt weitere Einplanung | erfüllt am 22.10.2026 |
| Rückkehr nach Entfernen | Browser entfernt die letzte Reserveeinplanung und bietet das Original sofort wieder an | erfüllt am 22.10.2026 |
| Angepasste Einplanung dupliziert | Service und Browser prüfen Position, Texte, Notizen, Dauer und Reload | erfüllt am 22.10.2026 |
| Live-Zustand des Duplikats zurückgesetzt | Servicetest prüft `open`, neue ID und fehlende Zeitintervalle | erfüllt am 22.10.2026 |
| Original unverändert | Piece-Anzahl und Original-ID vor und nach Einplanungsduplikation verglichen | erfüllt am 22.10.2026 |
| Speicherfehler nicht als Erfolg dargestellt | veraltete Revision rollt Duplikation vollständig zurück | erfüllt am 22.10.2026 |
| Globale aktive Einplanung ausgeblendet | Browser prüft den unabhängigen Filter über mehrere nicht abgeschlossene Sendungen | erfüllt am 22.10.2026 |
| Snapshot ausdrücklich aktualisiert | Service prüft neue Originaltexte und erhaltene Notizen/Intervalle; Browser löst Aktion aus | erfüllt am 22.10.2026 |
| Fehlendes Original atomar behandelt | Servicetest prüft Fehler und unveränderte Sendungsrevision | erfüllt am 22.10.2026 |
| Verwendungsdialog responsiv | Browser prüft Desktop/Mobil, Reserve, erreichbare Aktionen und sichtbare Konfliktfehler | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Fundus und Einplanung
- `npm test`: 22 Tests in 5 Dateien erfolgreich
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- gezielter Playwright-Lauf: 4 von 4 Browsertests erfolgreich
- `npm run test:e2e`: 12 von 12 Browsertests erfolgreich
- Desktopansichten von Fundushistorie, kombinierten Sendungsfiltern und
Verwendungsdialog sowie mobile Quellspalte und mobiler Dialog bei 390 × 844
Pixeln visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Redaktionelles Piece-Arbeitsblatt
| Kriterium | Nachweis | Stand |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Piece-Arbeitsblatt
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
1 von 1 Browsertest erfolgreich
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich - `bash -n start.sh` und `git diff --check`: erfolgreich
+75 -13
View File
@@ -97,6 +97,10 @@ erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständi
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt. unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
diese Felder nicht.
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.
@@ -124,9 +128,28 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt. weitere Bearbeitung geschützt.
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
Überleitung und Präsentation aus dem sendefertigen Piece kopiert. Eigene Notizen Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Metadaten und validierte Anbieter-ID. Eigene Notizen liegen ausschließlich an
der Verwendung. Die Referenz zum Original bleibt für Historie und
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
und den Präsentationszustand atomar aus dem referenzierten Original. Eigene
Notizen, Status und vorhandene Live-Intervalle werden dabei nicht verändert;
fehlt das Original oder ist die Sendungsrevision veraltet, wird die gesamte
Mutation verworfen.
Eine Einplanung zu duplizieren kopiert genau diesen Snapshot mit derselben
Original-ID direkt hinter das Ausgangselement. Die neue Verwendung erhält den
Status `open`, eine neue ID und damit weder Live-Referenzen noch Zeitintervalle.
Die Verwendungshistorie wird nicht separat gepflegt. Sie wird über die
Original-ID aus Verwendungen mit Status `done` in abgeschlossenen Sendungen
abgeleitet; Reservebank und alle anderen Verwendungs- oder Sendungsstatus sind
ausgeschlossen. Über dieselbe Original-ID wird unabhängig davon ermittelt, ob
mindestens eine Verwendung in einer noch nicht abgeschlossenen Sendung liegt.
Der allgemeine Fundus blendet diesen globalen Kontext standardmäßig aus. Die
Quellspalte einer Sendung betrachtet für ihren separaten Einplanungsfilter nur
die aktuelle Sendung einschließlich Reservebank.
Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch
verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die
@@ -139,17 +162,24 @@ die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung könne
die Vorlage deshalb nicht verändern. die Vorlage deshalb nicht verändern.
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Dadurch bleibt das Dokument bei Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
Planungsmutationen bestehen; nur die serverseitigen Seitendaten werden nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen Abschnitt Dokument bei Planungsmutationen bestehen; nur die serverseitigen Seitendaten
oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen entweder werden aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen
eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben Abschnitt oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen
entweder eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben
serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des
Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige
Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete
Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst
beim Loslassen. beim Loslassen.
Die Ausschlussfilter für gesendete und bereits eingeplante Pieces arbeiten
clientseitig auf der serverseitig berechneten Kontextmenge. Ihr Zustand wird
ohne Navigation in die URL übernommen und bei nachfolgenden Suchfiltern
mitgesendet. Planmutationen invalidieren anschließend die Seitendaten, sodass
das Entfernen der letzten Einplanung ein Original unmittelbar wieder anbietet.
## Live-Modus und Präsentationsgrenze ## Live-Modus und Präsentationsgrenze
`src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der `src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der
@@ -168,7 +198,27 @@ neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören. nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
erst nach einer ausdrücklichen Snapshot-Aktualisierung.
Linkmetadaten werden nur auf ausdrückliche Aktion durch
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
serverseitig auf, sperrt lokale und private IPv4-/IPv6-Ziele und bindet den
Request an die geprüfte Adresse. Dieselbe Prüfung gilt nach höchstens drei
Redirects. Zeit- und Größenlimits begrenzen HTML, Anbieter-Metadaten und Bilder.
Externe Vorschaubilder gelangen nur über token- beziehungsweise
sitzungsgebundene Proxyrouten in die Ausgabe; fremdes HTML und fremde Skripte
werden nie übernommen.
Videoquellen werden aus der gespeicherten URL erneut auf eine streng validierte
YouTube- oder TikTok-ID reduziert. Der Browser erhält nur daraus erzeugte
offizielle Embed-URLs, startet sie nicht automatisch und ersetzt das Iframe bei
jedem Inhaltswechsel. YouTube erhält die Startsekunde als Embed-Parameter;
TikTok wird nach dessen Bereitschaft über die offizielle Player-`postMessage`-
Schnittstelle positioniert. Nicht unterstützte oder unvollständige Videoquellen
fallen auf eine normale Linkkarte zurück. Eine authentifizierte Testansicht
verwendet denselben Renderer, schreibt aber keinen Live-Zustand.
Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per
`no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler `no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler
@@ -189,10 +239,22 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation. ohne interne Anwendungsnavigation.
## Sicherheitsgrenzen ab den Folgeetappen Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
kontrollierte Redirects. DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits.
+52 -1
View File
@@ -1,6 +1,6 @@
# Entscheidungen # Entscheidungen
Stand der Quellenprüfung: 21. Oktober 2026. Verwendet wurden offizielle Stand der Quellenprüfung: 22. Oktober 2026. Verwendet wurden offizielle
Dokumentationen und die npm-Registry für konkrete Paket-Metadaten. Dokumentationen und die npm-Registry für konkrete Paket-Metadaten.
## D-001: SvelteKit 3 und Node-Unterstützung ## D-001: SvelteKit 3 und Node-Unterstützung
@@ -225,3 +225,54 @@ Ein zufälliger 256-Bit-Token steht in der URL, während SQLite nur seinen
SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und
Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`- Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`-
Polling statt über eine dauerhaft offene Serververbindung. Polling statt über eine dauerhaft offene Serververbindung.
## D-018: Verwendung aus bestehenden Sendungsdaten ableiten
„Gesendet“ ist kein Zustand am Fundus-Piece. Die Anzeige wird ausschließlich
aus der vorhandenen Original-ID, einer erledigten Verwendung außerhalb der
Reservebank und dem Status `completed` ihrer Sendung abgeleitet. Dadurch können
Einplanung, Reserve und Überspringen ein Original nicht versehentlich als
gesendet markieren, und mehrere tatsächliche Verwendungen bleiben als Historie
sichtbar.
Eine Duplikation im Sendungsablauf kopiert den konkreten Verwendungssnapshot und
nicht das Original-Piece. Die neue ID, der Status `open` und fehlende
Live-Intervalle trennen sie von der bisherigen Durchführung. Der allgemeine
Verwendungseditor darf den Live-Status nicht mehr aus Formulardaten schreiben;
Statuswechsel bleiben der Live-Geschäftslogik vorbehalten.
Eine bestehende Einplanung wird nicht automatisch mit späteren Änderungen am
Original synchronisiert. Die ausdrücklich ausgelöste Aktion **Aus Fundus
aktualisieren** überschreibt nur Titel, Dauer und Redaktionstexte. Eigene
Notizen, Status und Messintervalle bleiben erhalten. Alle Zuordnungen und Filter
verwenden ausschließlich `original_piece_id`, niemals veränderliche Titel. Im
allgemeinen Fundus gilt „eingeplant“ global für jede nicht abgeschlossene
Sendung; im Sendungsbau gilt es lokal für die aktuelle Sendung samt Reserve.
## D-019: Lokale Linkmetadaten und streng abgeleitete Video-Embeds
Sendewerk verwendet Metascraper ausschließlich serverseitig, um Titel,
Beschreibung, Bild und Herausgeber einer ausdrücklich gewählten vorhandenen
Quelle zu ermitteln. Die Ergebnisse werden gespeichert und nicht bei jedem
Seitenaufruf neu geladen. Manuell korrigierte Felder haben Vorrang. Ein eigener
HTTP(S)-Client prüft Protokoll, Port, DNS-Ergebnisse und jedes Redirectziel,
bindet den Request an die geprüfte öffentliche Adresse und begrenzt Redirects,
Laufzeit und Antwortgröße. Damit ist weder ein externer Vorschaudienst noch die
Übernahme fremden HTMLs erforderlich.
Player werden nur für streng erkannte YouTube- und TikTok-URLs erzeugt. Aus der
validierten Anbieter-ID baut Sendewerk die offiziellen Embed-Adressen selbst;
gespeichertes oder fremd geliefertes Embed-HTML wird nicht verwendet. YouTube
nutzt die datenschutzreduzierte `youtube-nocookie.com`-Adresse und den
`start`-Parameter. TikTok erhält eine Startposition nach `onPlayerReady` über
die dokumentierte Player-`postMessage`-API. Autoplay bleibt ausgeschaltet, und
ein Inhaltswechsel ersetzt das Iframe vollständig.
Quellen:
- <https://github.com/microlinkhq/metascraper>
- <https://developers.google.com/youtube/player_parameters>
- <https://developers.google.com/youtube/terms/required-minimum-functionality>
- <https://developers.tiktok.com/doc/embed-player>
- <https://developers.tiktok.com/doc/embed-videos>
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
+36
View File
@@ -67,6 +67,20 @@ als bereits implementiert verstanden werden.
- Archivieren und Wiederherstellen in den vorherigen Arbeitsstatus - Archivieren und Wiederherstellen in den vorherigen Arbeitsstatus
- private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung - private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung
- erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen - erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen
- standardmäßig ausgeblendete gesendete Pieces mit verlinkter, aufklappbarer
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- der eigenständige Piece-Editor ist als redaktionelles Arbeitsblatt aufgebaut:
Titel, Fundstück, private Recherche und die getrennt gespeicherten
Sendungstexte stehen im Schreibfluss; Quellen, Einordnung, Präsentation und
Bilder begleiten ihn als Desktop-Kontextleiste oder mobile Werkzeuge
- Markdown-Felder wechseln einzeln zwischen Lesen und Bearbeiten, sodass mehrere
Textbereiche gleichzeitig offen bleiben; Autosave, Konfliktschutz und
sichtbare Speicherfehler verwenden unverändert die Piece-Revision
- die Schnellaufnahme „Aufnehmen und öffnen“ leitet nach erfolgreicher Anlage
direkt zum neuen Piece weiter, statt den SvelteKit-Redirect als
Validierungsfehler abzufangen
## Umgesetzter Stand Etappe 4 ## Umgesetzter Stand Etappe 4
@@ -78,6 +92,12 @@ als bereits implementiert verstanden werden.
- showweite Reservebank, die nicht in Abschnitts- oder Gesamtdauer einfließt - showweite Reservebank, die nicht in Abschnitts- oder Gesamtdauer einfließt
- explizite Abschnittsdauer ersetzt die Piece-Summe statt sie zu addieren - explizite Abschnittsdauer ersetzt die Piece-Summe statt sie zu addieren
- Bearbeiten, Entfernen sowie Verschieben innerhalb und zwischen Abschnitten - Bearbeiten, Entfernen sowie Verschieben innerhalb und zwischen Abschnitten
- zugänglicher Stift-Dialog für alle sendungsspezifischen Felder und Aktionen,
auf Desktop zweispaltig und auf kleinen Bildschirmen einspaltig scrollbar
- explizites Aktualisieren eines Verwendungssnapshots aus dem Original für
Titel, Dauer und Redaktionstexte bei erhaltenen Notizen und Live-Daten
- Duplizieren einer konkreten Einplanung direkt hinter dem Ausgangselement mit
erhaltenen Anpassungen, aber zurückgesetztem Live-Zustand
- Piece-Dauern werden im Editor in Minuten eingegeben und intern weiterhin als - Piece-Dauern werden im Editor in Minuten eingegeben und intern weiterhin als
ganze Sekunden gespeichert ganze Sekunden gespeichert
- Drag-and-drop über eindeutige Griffe, auch direkt aus gefilterten Quellen, - Drag-and-drop über eindeutige Griffe, auch direkt aus gefilterten Quellen,
@@ -85,6 +105,8 @@ als bereits implementiert verstanden werden.
gleichwertigen Klick-, Touch- und Tastaturaktionen gleichwertigen Klick-, Touch- und Tastaturaktionen
- Planungsmutationen ohne Dokumentreload mit lokalem Speicherzustand und - Planungsmutationen ohne Dokumentreload mit lokalem Speicherzustand und
animierter Neusortierung der betroffenen Elemente animierter Neusortierung der betroffenen Elemente
- unabhängige Sofortfilter für verwendete sowie in der aktuellen Sendung schon
eingeplante Originale einschließlich Reservebank
- kopierbare Twitch-Titel und Ankündigungstexte - kopierbare Twitch-Titel und Ankündigungstexte
- showweite optimistische Revision schützt Metadaten, Reihenfolge und Inhalte - showweite optimistische Revision schützt Metadaten, Reihenfolge und Inhalte
vor unbemerktem parallelem Überschreiben vor unbemerktem parallelem Überschreiben
@@ -104,6 +126,16 @@ als bereits implementiert verstanden werden.
nicht nicht
- getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige, - getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige,
Präsentationstext, Quellen und erlaubten Bildern Präsentationstext, Quellen und erlaubten Bildern
- wählbare Text-, Linkkarten- und Videodarstellung aus genau einer vorhandenen
Piece-Quelle mit manuell korrigierbaren, persistenten Metadaten
- ausdrückliches serverseitiges Neuladen von Linkmetadaten über Metascraper mit
DNS-, Redirect-, Zeit-, Größen- und Privatnetzschutz
- validierte YouTube- und TikTok-IDs, offizielle Player-Iframes ohne Autoplay
sowie Linkkarten-Fallback für nicht unterstützte Videoquellen
- unveränderliche Präsentationssnapshots je Einplanung und explizite
Aktualisierung aus dem Fundus
- isolierte Präsentationstestansicht ohne Änderung des Live-Stands sowie
tokengebundene, serverseitig geschützte Proxys für externe Vorschaubilder
- Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche, - Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche,
Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade
@@ -120,6 +152,10 @@ Präsentationsausgabe wurde stattdessen in einem anonymen zweiten Chromium-
Browserkontext einschließlich Bild, JSON-Filterung, Polling und sichtbarem Browserkontext einschließlich Bild, JSON-Filterung, Polling und sichtbarem
Verbindungsabbruch geprüft. Verbindungsabbruch geprüft.
Die automatisierten Playerprüfungen fingen YouTube- und TikTok-Requests lokal
ab. URL-Erkennung, Iframe-Wechsel, Zeitmarkenübergabe und mobile Ausgabe sind
damit geprüft, nicht jedoch die reale Wiedergabe oder Verfügbarkeit der Anbieter.
## Verifizierte Umgebung ## Verifizierte Umgebung
- Node.js 22.23.1, npm 10.9.8 - Node.js 22.23.1, npm 10.9.8
+28
View File
@@ -0,0 +1,28 @@
ALTER TABLE pieces ADD COLUMN presentation_mode TEXT NOT NULL DEFAULT 'text'
CHECK (presentation_mode IN ('text', 'link', 'video'));
ALTER TABLE pieces ADD COLUMN presentation_source_position INTEGER;
ALTER TABLE pieces ADD COLUMN presentation_title TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_description TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_image_url TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_site_name TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_provider TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_provider_id TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_title_manual INTEGER NOT NULL DEFAULT 0
CHECK (presentation_title_manual IN (0, 1));
ALTER TABLE pieces ADD COLUMN presentation_description_manual INTEGER NOT NULL DEFAULT 0
CHECK (presentation_description_manual IN (0, 1));
ALTER TABLE pieces ADD COLUMN presentation_image_manual INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_manual IN (0, 1));
ALTER TABLE pieces ADD COLUMN presentation_metadata_fetched_at_utc TEXT;
ALTER TABLE piece_usages ADD COLUMN presentation_mode_snapshot TEXT NOT NULL DEFAULT 'text'
CHECK (presentation_mode_snapshot IN ('text', 'link', 'video'));
ALTER TABLE piece_usages ADD COLUMN presentation_source_label_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_source_url_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_source_timecode_snapshot INTEGER;
ALTER TABLE piece_usages ADD COLUMN presentation_title_meta_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_description_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_image_url_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_site_name_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_provider_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_provider_id_snapshot TEXT NOT NULL DEFAULT '';
+1591 -7
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.0", "version": "0.5.2",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -28,6 +28,11 @@
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"file-type": "^22.2.0", "file-type": "^22.2.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"metascraper": "^5.58.5",
"metascraper-description": "^5.58.4",
"metascraper-image": "^5.58.4",
"metascraper-publisher": "^5.58.4",
"metascraper-title": "^5.58.4",
"nodemailer": "^10.1.7", "nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1" "sanitize-html": "^2.18.1"
}, },
+1060 -8
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -14,6 +14,7 @@ const publicPaths = new Set([
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/'); const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies); if (token && !event.locals.user) deleteSession(event.cookies);
@@ -26,9 +27,13 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
if (presentationPath) { if (presentationPath || presentationTestPath) {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
response.headers.set('Referrer-Policy', 'no-referrer'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Content-Security-Policy',
"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'"
);
} }
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
@@ -0,0 +1,60 @@
<script lang="ts">
import MarkdownPreview from './MarkdownPreview.svelte';
let {
name,
label,
inputLabel = label,
hint = '',
emptyText,
value = $bindable(),
html,
privateField = false,
rows = 7
}: {
name: string;
label: string;
inputLabel?: string;
hint?: string;
emptyText: string;
value: string;
html: string;
privateField?: boolean;
rows?: number;
} = $props();
let editing = $state(false);
</script>
<section class:private-field={privateField} class:empty={!value.trim()} class="editorial-field">
<header>
<div>
<h3>{label}</h3>
{#if hint}<p>{hint}</p>{/if}
</div>
<button
class="text-button editorial-edit-button"
type="button"
aria-expanded={editing}
onclick={() => (editing = !editing)}
>
{editing ? 'Lesen' : value.trim() ? 'Bearbeiten' : 'Schreiben'}
</button>
</header>
{#if editing}
<label class="editorial-input">
<span class="sr-only">{inputLabel}</span>
<textarea aria-label={inputLabel} {name} {rows} maxlength="50000" bind:value></textarea>
</label>
{:else}
<input type="hidden" {name} {value} />
{#if value.trim()}
<MarkdownPreview {html} />
{:else}
<button class="editorial-empty" type="button" onclick={() => (editing = true)}>
<span>{emptyText}</span><small>Zum Schreiben öffnen</small>
</button>
{/if}
{/if}
</section>
+2
View File
@@ -16,6 +16,8 @@
<path <path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4" d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/> />
{:else if name === 'edit'}
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
{/if} {/if}
</svg> </svg>
@@ -0,0 +1,36 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { SentPieceUsage } from '#lib/server/shows.js';
let { history }: { history: SentPieceUsage[] } = $props();
function berlinDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium'
}).format(new Date(value));
}
</script>
{#if history.length}
<div class="piece-usage-history">
<p>
<strong>Gesendet am {berlinDate(history[0].usedAtUtc)}</strong>
in <a href={resolve('/sendungen/[id]', { id: history[0].showId })}>{history[0].showTitle}</a>
{#if history.length > 1}<span>+ {history.length - 1} weitere</span>{/if}
</p>
{#if history.length > 1}
<details>
<summary>Vollständige Verwendungshistorie</summary>
<ul>
{#each history as usage (usage.usageId)}
<li>
<time datetime={usage.usedAtUtc}>{berlinDate(usage.usedAtUtc)}</time>
<a href={resolve('/sendungen/[id]', { id: usage.showId })}>{usage.showTitle}</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
let {
content,
imageSrc = ''
}: {
content: PresentationContent & {
sources?: { label: string; url: string }[];
images?: { id: string; altText: string }[];
};
imageSrc?: string;
} = $props();
let frame = $state<HTMLIFrameElement>();
let playerUnavailable = $state(false);
let imageUnavailable = $state(false);
onMount(() => {
const receive = (event: MessageEvent) => {
if (content.provider !== 'tiktok' || event.origin !== 'https://www.tiktok.com') return;
if (event.source !== frame?.contentWindow || typeof event.data !== 'object' || !event.data)
return;
const message = event.data as { type?: string; value?: unknown };
if (message.type === 'onPlayerReady' && content.source?.timecodeSeconds) {
frame.contentWindow?.postMessage(
{
type: 'seekTo',
value: content.source.timecodeSeconds,
'x-tiktok-player': true
},
'https://www.tiktok.com'
);
}
if (message.type === 'onPlayerError') playerUnavailable = true;
};
addEventListener('message', receive);
return () => removeEventListener('message', receive);
});
</script>
{#if content.mode === 'text'}
<article class="output-content output-text">
<p class="output-kicker">heinzlive</p>
<h1>{content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html content.html}</div>
{#if content.images?.length}<div class="output-images">
{#each content.images as image (image.id)}
<img src={`${imageSrc}/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if content.sources?.length}<footer class="output-sources">
<span>Quellen</span>
{#each content.sources as source, index (`${index}-${source.url}`)}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else if content.mode === 'link'}
<article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
</div>{/if}
<div class="output-link-copy">
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
<h1>{content.previewTitle || content.title}</h1>
{#if content.description}<p>{content.description}</p>{/if}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</div>
</article>
{:else}
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
<header>
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
<h1>{content.previewTitle || content.title}</h1>
</header>
{#if content.embedUrl && !playerUnavailable}
<div class="output-player">
<iframe
bind:this={frame}
title={content.previewTitle || content.title}
src={content.embedUrl}
allow="encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
onerror={() => (playerUnavailable = true)}
></iframe>
</div>
{:else}
<section class="output-fallback">
<strong>Video kann nicht eingebettet werden</strong>
<p>Der Anbieter oder diese URL ist für die eingebettete Wiedergabe nicht verfügbar.</p>
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</section>
{/if}
{#if content.source}<footer class="output-video-source">
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={content.source.url} target="_blank" rel="noopener noreferrer">Original öffnen</a>
</footer>{/if}
</article>
{/if}
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import NavIcon from './NavIcon.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
usage,
revision,
sectionId,
sections,
error,
enhanceCommand
}: {
usage: PieceUsage;
revision: number;
sectionId: string;
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
} = $props();
let dialog: HTMLDialogElement;
let presentationMode = $state(untrack(() => usage.presentationMode));
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
</script>
<button
class="usage-edit-button"
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()}
>
<NavIcon name="edit" />
</button>
<dialog
bind:this={dialog}
class="usage-editor-dialog"
aria-labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()}
>
<header>
<div>
<p class="eyebrow">Sendungsspezifische Kopie</p>
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
</div>
<form method="dialog">
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
</form>
</header>
<div class="usage-dialog-scroll">
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-dialog-editor"
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="editor-section">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h3>Titel, Dauer und Notizen</h3>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" value={usage.title} /></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h3>Getrennte Arbeitsbereiche</h3>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
>{usage.intro}</textarea
></label
>
<label class="private-field"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
>{usage.research}</textarea
></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderation"
rows="7"
maxlength="50000">{usage.moderation}</textarea
></label
>
<label
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
>{usage.transition}</textarea
></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
>{usage.presentation}</textarea
></label
>
</div>
<div class="presentation-settings">
<label
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
<option value="text">Text</option>
<option value="link">Linkvorschau</option>
<option value="video">Video</option>
</select></label
>
{#if presentationMode !== 'text'}
<input
type="hidden"
name="presentationSourceLabel"
value={usage.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={usage.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{usage.presentationSourceLabel ||
usage.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
value={usage.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>
{/if}
</div>
</section>
<button class="primary-button" type="submit">Verwendung speichern</button>
</form>
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
<div>
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
>Aus Fundus aktualisieren</button
>
</form>
<small
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit">Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="position" value="9999" />
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each sections as target (target.id)}
<option value={target.id} selected={target.id === sectionId}>{target.name}</option>
{/each}
</select></label
>
<button class="secondary-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button danger-text" type="submit">Entfernen</button>
</form>
</section>
</div>
</dialog>
+56
View File
@@ -13,6 +13,7 @@ import {
getPiece, getPiece,
getTaxonomy, getTaxonomy,
listPieces, listPieces,
loadPiecePresentationMetadata,
savePiece, savePiece,
setPieceArchived, setPieceArchived,
uploadPieceImage, uploadPieceImage,
@@ -50,6 +51,18 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
moderationNotes: '- Punkt eins', moderationNotes: '- Punkt eins',
transitionText: 'Weiter geht es.', transitionText: 'Weiter geht es.',
presentationText: '## Sichtbare Aussage', presentationText: '## Sichtbare Aussage',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
presentationDescription: '',
presentationImageUrl: '',
presentationSiteName: '',
presentationProvider: '',
presentationProviderId: '',
presentationTitleManual: false,
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: ['topic-gaming', 'topic-internet'], topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction'], categoryIds: ['cat-jc', 'cat-reaction'],
sources: [ sources: [
@@ -86,6 +99,49 @@ describe('content management', () => {
}); });
}); });
it('stores fetched preview metadata and preserves manual corrections on reload', async () => {
const id = createPiece('find');
const initial = getPiece(id)!;
const saved = savePiece(
id,
initial.revision,
completeInput({
presentationMode: 'link',
presentationSourcePosition: 0,
presentationTitle: 'Manueller Titel',
presentationTitleManual: true
})
);
const loaded = await loadPiecePresentationMetadata(id, saved.revision, async () => ({
title: 'Automatischer Titel',
description: 'Geladener Anreißer',
imageUrl: 'https://example.com/vorschau.jpg',
siteName: 'Example',
provider: '',
providerId: ''
}));
expect(getPiece(id)).toMatchObject({
revision: loaded.revision,
presentationTitle: 'Manueller Titel',
presentationDescription: 'Geladener Anreißer',
presentationImageUrl: 'https://example.com/vorschau.jpg',
presentationSiteName: 'Example'
});
await loadPiecePresentationMetadata(id, loaded.revision, async () => ({
title: 'Anderer automatischer Titel',
description: 'Aktualisierter Anreißer',
imageUrl: '',
siteName: 'Example',
provider: '',
providerId: ''
}));
expect(getPiece(id)).toMatchObject({
presentationTitle: 'Manueller Titel',
presentationDescription: 'Aktualisierter Anreißer'
});
});
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => { it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte'); createTaxonomy('topic', 'Technikgeschichte');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!; const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
+190 -3
View File
@@ -4,6 +4,11 @@ import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { getServerPaths } from './config.js'; import { getServerPaths } from './config.js';
import {
detectVideoProvider,
fetchPresentationMetadata,
type PresentationMode
} from './presentation.js';
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const; export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number]; export type PieceStatus = (typeof PIECE_STATUSES)[number];
@@ -27,6 +32,18 @@ export type PieceInput = {
moderationNotes: string; moderationNotes: string;
transitionText: string; transitionText: string;
presentationText: string; presentationText: string;
presentationMode: PresentationMode;
presentationSourcePosition: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProviderId: string;
presentationTitleManual: boolean;
presentationDescriptionManual: boolean;
presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null;
topicIds: string[]; topicIds: string[];
categoryIds: string[]; categoryIds: string[];
sources: PieceSourceInput[]; sources: PieceSourceInput[];
@@ -164,6 +181,25 @@ function validatePieceInput(input: PieceInput): PieceInput {
} }
return { label, url, timecodeSeconds: source.timecodeSeconds }; return { label, url, timecodeSeconds: source.timecodeSeconds };
}); });
if (!['text', 'link', 'video'].includes(input.presentationMode)) {
throw new Error('INVALID_PRESENTATION');
}
const sourcePosition = input.presentationSourcePosition;
if (
sourcePosition !== null &&
(!Number.isInteger(sourcePosition) || sourcePosition < 0 || sourcePosition >= sources.length)
) {
throw new Error('INVALID_PRESENTATION');
}
if (input.presentationMode !== 'text' && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION');
}
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
if (presentationImageUrl && !validUrl(presentationImageUrl)) {
throw new Error('INVALID_PRESENTATION');
}
const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return { return {
title, title,
summary: cleanText(input.summary, 2_000, 'summary'), summary: cleanText(input.summary, 2_000, 'summary'),
@@ -175,6 +211,22 @@ function validatePieceInput(input: PieceInput): PieceInput {
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'), moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'), transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'), presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
presentationMode: input.presentationMode,
presentationSourcePosition: sourcePosition,
presentationTitle: cleanText(input.presentationTitle, 300, 'presentation_title'),
presentationDescription: cleanText(
input.presentationDescription,
1_000,
'presentation_description'
),
presentationImageUrl,
presentationSiteName: cleanText(input.presentationSiteName, 200, 'presentation_site'),
presentationProvider: detected?.provider ?? '',
presentationProviderId: detected?.id ?? '',
presentationTitleManual: Boolean(input.presentationTitleManual),
presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
topicIds, topicIds,
categoryIds, categoryIds,
sources sources
@@ -298,6 +350,18 @@ type PieceRow = {
moderation_notes: string; moderation_notes: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_position: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
presentation_title_manual: number;
presentation_description_manual: number;
presentation_image_manual: number;
presentation_metadata_fetched_at_utc: string | null;
revision: number; revision: number;
created_at_utc: string; created_at_utc: string;
updated_at_utc: string; updated_at_utc: string;
@@ -308,7 +372,12 @@ export function getPiece(id: string): PieceEditor | null {
const row = database const row = database
.prepare( .prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text, `SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
presentation_image_manual, presentation_metadata_fetched_at_utc,
revision, created_at_utc, updated_at_utc
FROM pieces WHERE id = ?` FROM pieces WHERE id = ?`
) )
.get(id) as PieceRow | undefined; .get(id) as PieceRow | undefined;
@@ -325,6 +394,18 @@ export function getPiece(id: string): PieceEditor | null {
moderationNotes: row.moderation_notes, moderationNotes: row.moderation_notes,
transitionText: row.transition_text, transitionText: row.transition_text,
presentationText: row.presentation_text, presentationText: row.presentation_text,
presentationMode: row.presentation_mode,
presentationSourcePosition: row.presentation_source_position,
presentationTitle: row.presentation_title,
presentationDescription: row.presentation_description,
presentationImageUrl: row.presentation_image_url,
presentationSiteName: row.presentation_site_name,
presentationProvider: row.presentation_provider,
presentationProviderId: row.presentation_provider_id,
presentationTitleManual: row.presentation_title_manual === 1,
presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
revision: row.revision, revision: row.revision,
createdAtUtc: row.created_at_utc, createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc, updatedAtUtc: row.updated_at_utc,
@@ -405,6 +486,11 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
.prepare( .prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, `UPDATE pieces SET 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_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?,
presentation_metadata_fetched_at_utc = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived'`
) )
@@ -419,6 +505,18 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.moderationNotes, input.moderationNotes,
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
input.presentationMode,
input.presentationSourcePosition,
input.presentationTitle,
input.presentationDescription,
input.presentationImageUrl,
input.presentationSiteName,
input.presentationProvider,
input.presentationProviderId,
input.presentationTitleManual ? 1 : 0,
input.presentationDescriptionManual ? 1 : 0,
input.presentationImageManual ? 1 : 0,
input.presentationMetadataFetchedAtUtc,
updatedAtUtc, updatedAtUtc,
id, id,
expectedRevision expectedRevision
@@ -477,6 +575,18 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
moderationNotes: defaults.moderationNotes ?? '', moderationNotes: defaults.moderationNotes ?? '',
transitionText: '', transitionText: '',
presentationText: defaults.presentationText ?? '', presentationText: defaults.presentationText ?? '',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
presentationDescription: '',
presentationImageUrl: '',
presentationSiteName: '',
presentationProvider: '',
presentationProviderId: '',
presentationTitleManual: false,
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: [], topicIds: [],
categoryIds: defaults.categoryIds ?? [], categoryIds: defaults.categoryIds ?? [],
sources: [] sources: []
@@ -582,8 +692,13 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, 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, presentation_mode,
updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)` presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
@@ -596,6 +711,18 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.moderationNotes, source.moderationNotes,
source.transitionText, source.transitionText,
source.presentationText, source.presentationText,
source.presentationMode,
source.presentationSourcePosition,
source.presentationTitle,
source.presentationDescription,
source.presentationImageUrl,
source.presentationSiteName,
source.presentationProvider,
source.presentationProviderId,
source.presentationTitleManual ? 1 : 0,
source.presentationDescriptionManual ? 1 : 0,
source.presentationImageManual ? 1 : 0,
source.presentationMetadataFetchedAtUtc,
now, now,
now now
); );
@@ -627,6 +754,66 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
} }
} }
export async function loadPiecePresentationMetadata(
id: string,
expectedRevision: number,
metadataLoader = fetchPresentationMetadata
) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const database = getDatabase();
const piece = database
.prepare(
`SELECT p.revision, p.presentation_mode, p.presentation_source_position,
p.presentation_title_manual, p.presentation_description_manual,
p.presentation_image_manual, s.url
FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id
AND s.position = p.presentation_source_position
WHERE p.id = ? AND p.status != 'archived'`
)
.get(id) as
| {
revision: number;
presentation_mode: PresentationMode;
presentation_source_position: number | null;
presentation_title_manual: number;
presentation_description_manual: number;
presentation_image_manual: number;
url: string | null;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (piece.revision !== expectedRevision) throw new Error('CONFLICT');
if (piece.presentation_mode === 'text' || !piece.url) throw new Error('INVALID_PRESENTATION');
const metadata = await metadataLoader(piece.url);
const now = new Date().toISOString();
const result = database
.prepare(
`UPDATE pieces SET
presentation_title = CASE WHEN presentation_title_manual = 1 THEN presentation_title ELSE ? END,
presentation_description = CASE WHEN presentation_description_manual = 1 THEN presentation_description ELSE ? END,
presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END,
presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?,
presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(
metadata.title,
metadata.description,
metadata.imageUrl,
metadata.siteName,
metadata.provider,
metadata.providerId,
now,
now,
id,
expectedRevision
);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function uploadPieceImage( export async function uploadPieceImage(
pieceId: string, pieceId: string,
expectedRevision: number, expectedRevision: number,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 5, migrations: 6,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+48
View File
@@ -134,4 +134,52 @@ describe('live mode', () => {
'Die kleine Konsole mit dem großen Dreh' 'Die kleine Konsole mit dem großen Dreh'
); );
}); });
it('projects only snapshotted link and video data and changes players explicitly', () => {
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'video',
presentation_source_label_snapshot = 'YouTube',
presentation_source_url_snapshot = 'https://youtu.be/dQw4w9WgXcQ',
presentation_source_timecode_snapshot = 75,
presentation_title_meta_snapshot = 'Videotitel',
presentation_site_name_snapshot = 'YouTube',
presentation_provider_snapshot = 'youtube',
presentation_provider_id_snapshot = 'dQw4w9WgXcQ'
WHERE id = 'usage-console'`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'link',
presentation_source_label_snapshot = 'Artikel',
presentation_source_url_snapshot = 'https://example.com/artikel',
presentation_title_meta_snapshot = 'Linktitel',
presentation_description_snapshot = 'Öffentlicher Anreißer',
presentation_site_name_snapshot = 'Example'
WHERE id = 'usage-effects'`
)
.run();
const token = issuePresentationAccess('show-october');
presentUsage('show-october', 'usage-console', 'present-video', 0);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'video',
previewTitle: 'Videotitel',
embedUrl: 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75',
source: { url: 'https://youtu.be/dQw4w9WgXcQ', timecodeSeconds: 75 }
});
presentUsage('show-october', 'usage-effects', 'present-link', 1);
const link = getPresentationView(token)!;
expect(link.content).toMatchObject({
mode: 'link',
previewTitle: 'Linktitel',
description: 'Öffentlicher Anreißer',
embedUrl: '',
source: { label: 'Artikel', url: 'https://example.com/artikel' }
});
const serialized = JSON.stringify(link);
expect(serialized).not.toContain('Herstellerangaben');
expect(serialized).not.toContain('Moderationsstichpunkte');
});
}); });
+80 -20
View File
@@ -3,6 +3,7 @@ import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js'; 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 { import {
getShow, getShow,
movePieceUsage, movePieceUsage,
@@ -67,8 +68,17 @@ export type LiveView = {
export type PresentationView = { export type PresentationView = {
revision: number; revision: number;
content: null | { content: null | {
mode: PresentationContent['mode'];
title: string; title: string;
html: string; html: string;
source: PresentationContent['source'];
previewTitle: string;
description: string;
imageUrl: string;
siteName: string;
provider: PresentationContent['provider'];
providerId: string;
embedUrl: string;
sources: { label: string; url: string; timecodeSeconds: number | null }[]; sources: { label: string; url: string; timecodeSeconds: number | null }[];
images: { id: string; altText: string }[]; images: { id: string; altText: string }[];
}; };
@@ -506,45 +516,95 @@ export function getPresentationView(token: string): PresentationView | null {
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.presentation_snapshot, u.original_piece_id,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
u.presentation_provider_snapshot, u.presentation_provider_id_snapshot
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0` WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
) )
.get(state.presentation_usage_id, showId) as .get(state.presentation_usage_id, showId) as
| { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null } | {
title_snapshot: string;
presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode'];
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
presentation_title_meta_snapshot: string;
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: PresentationContent['provider'];
presentation_provider_id_snapshot: string;
}
| undefined; | undefined;
if (!usage) return { revision: state.presentation_revision, content: null }; if (!usage) return { revision: state.presentation_revision, content: null };
const sources = usage.original_piece_id const sources =
? (database usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
.prepare( ? (database
`SELECT label, url, timecode_seconds AS timecodeSeconds .prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position` FROM piece_sources WHERE piece_id = ? ORDER BY position`
) )
.all(usage.original_piece_id) as { .all(usage.original_piece_id) as {
label: string; label: string;
url: string; url: string;
timecodeSeconds: number | null; timecodeSeconds: number | null;
}[]) }[])
: []; : [];
const images = usage.original_piece_id const images =
? (database usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
.prepare( ? (database
`SELECT id, alt_text AS altText FROM piece_media .prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position` WHERE piece_id = ? ORDER BY position`
) )
.all(usage.original_piece_id) as { id: string; altText: string }[]) .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,
title: usage.title_snapshot, title: usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot), html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot
? {
label: usage.presentation_source_label_snapshot,
url: usage.presentation_source_url_snapshot,
timecodeSeconds: usage.presentation_source_timecode_snapshot
}
: null,
previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot,
siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot,
embedUrl: buildEmbedUrl(
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot
),
sources, sources,
images images
} }
}; };
} }
export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null;
try {
return await fetchPresentationImage(view.content.imageUrl);
} catch {
return null;
}
}
export async function getPresentationMedia(token: string, mediaId: string) { export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase(); const database = getDatabase();
const showId = presentationShowId(database, token); const showId = presentationShowId(database, token);
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
buildEmbedUrl,
detectVideoProvider,
extractMetadataFromHtml,
fetchPresentationMetadata
} from './presentation.js';
describe('presentation metadata and providers', () => {
it('recognizes supported YouTube URL forms and creates privacy-enhanced embeds with start time', () => {
for (const url of [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://youtube.com/live/dQw4w9WgXcQ'
]) {
expect(detectVideoProvider(url)).toEqual({ provider: 'youtube', id: 'dQw4w9WgXcQ' });
}
expect(buildEmbedUrl('youtube', 'dQw4w9WgXcQ', 75)).toBe(
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
);
expect(detectVideoProvider('https://evil.example/watch?v=dQw4w9WgXcQ')).toBeNull();
expect(detectVideoProvider('https://youtube.com/watch?v=invalid')).toBeNull();
});
it('recognizes canonical TikTok videos without inventing a start parameter', () => {
expect(
detectVideoProvider('https://www.tiktok.com/@scout2015/video/6718335390845095173')
).toEqual({ provider: 'tiktok', id: '6718335390845095173' });
expect(buildEmbedUrl('tiktok', '6718335390845095173', 90)).toBe(
'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1'
);
});
it('extracts generic metadata from local HTML and tolerates a missing image', async () => {
const metadata = await extractMetadataFromHtml(
`<!doctype html><html><head>
<meta property="og:title" content="Lokale Vorschau">
<meta property="og:description" content="Ein kurzer Anreißer">
<meta property="og:site_name" content="Testseite">
</head></html>`,
'https://example.com/artikel'
);
expect(metadata).toMatchObject({
title: 'Lokale Vorschau',
description: 'Ein kurzer Anreißer',
imageUrl: '',
siteName: 'Testseite'
});
});
it('rejects malformed and private metadata targets before a network request', async () => {
await expect(fetchPresentationMetadata('keine-url')).rejects.toThrow('FETCH_INVALID_URL');
await expect(fetchPresentationMetadata('http://127.0.0.1/private')).rejects.toThrow(
'FETCH_BLOCKED'
);
await expect(fetchPresentationMetadata('http://[::1]/private')).rejects.toThrow(
'FETCH_BLOCKED'
);
});
});
+274
View File
@@ -0,0 +1,274 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import http from 'node:http';
import https from 'node:https';
import metascraper from 'metascraper';
import description from 'metascraper-description';
import image from 'metascraper-image';
import publisher from 'metascraper-publisher';
import title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = {
title: string;
description: string;
imageUrl: string;
siteName: string;
provider: VideoProvider | '';
providerId: string;
};
export type PresentationSource = {
label: string;
url: string;
timecodeSeconds: number | null;
};
export type PresentationContent = {
mode: PresentationMode;
title: string;
html: string;
source: PresentationSource | null;
previewTitle: string;
description: string;
imageUrl: string;
siteName: string;
provider: VideoProvider | '';
providerId: string;
embedUrl: string;
};
const scrape = metascraper([title(), description({ truncateLength: 500 }), image(), publisher()]);
const MAX_HTML_BYTES = 1024 * 1024;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
function isPrivateIpv4(address: string) {
const parts = address.split('.').map(Number);
return (
parts[0] === 0 ||
parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 169 && parts[1] === 254) ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) ||
parts[0] >= 224
);
}
function isPrivateAddress(address: string) {
if (isIP(address) === 4) return isPrivateIpv4(address);
const normalized = address.toLowerCase().split('%')[0];
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8')) {
return true;
}
if (
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
) {
return true;
}
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? isPrivateIpv4(mapped) : false;
}
async function publicAddress(url: URL) {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('FETCH_BLOCKED');
}
if (url.port && url.port !== (url.protocol === 'https:' ? '443' : '80')) {
throw new Error('FETCH_BLOCKED');
}
const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
throw new Error('FETCH_BLOCKED');
}
const addresses = isIP(hostname)
? [{ address: hostname, family: isIP(hostname) }]
: await lookup(hostname, { all: true, verbatim: true }).catch(() => {
throw new Error('FETCH_UNAVAILABLE');
});
if (!addresses.length || addresses.some((entry) => isPrivateAddress(entry.address))) {
throw new Error('FETCH_BLOCKED');
}
return addresses[0];
}
async function requestPublic(
input: string,
accept: string,
maximumBytes: number,
redirects = 0
): Promise<{ url: string; contentType: string; data: Buffer }> {
let url: URL;
try {
url = new URL(input);
} catch {
throw new Error('FETCH_INVALID_URL');
}
const target = await publicAddress(url);
const client = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const request = client.get(
url,
{
headers: {
Accept: accept,
'User-Agent': 'Sendewerk/0.5.1 (+https://git.hnz.io/hnzio/sendewerk)'
},
lookup: (_hostname, _options, callback) =>
callback(null, target.address, target.family as 4 | 6)
},
(response) => {
const status = response.statusCode ?? 0;
if (status >= 300 && status < 400 && response.headers.location) {
response.resume();
if (redirects >= MAX_REDIRECTS) return reject(new Error('FETCH_REDIRECTS'));
const next = new URL(response.headers.location, url).href;
requestPublic(next, accept, maximumBytes, redirects + 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
response.resume();
return reject(new Error('FETCH_UNAVAILABLE'));
}
const chunks: Buffer[] = [];
let size = 0;
response.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > maximumBytes) {
request.destroy(new Error('FETCH_TOO_LARGE'));
return;
}
chunks.push(chunk);
});
response.on('end', () =>
resolve({
url: url.href,
contentType: String(response.headers['content-type'] ?? ''),
data: Buffer.concat(chunks)
})
);
}
);
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('FETCH_TIMEOUT')));
request.on('error', reject);
});
}
export function detectVideoProvider(input: string): {
provider: VideoProvider;
id: string;
} | null {
let url: URL;
try {
url = new URL(input);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
const host = url.hostname.toLowerCase();
let youtubeId = '';
if (host === 'youtu.be') youtubeId = url.pathname.split('/').filter(Boolean)[0] ?? '';
if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'm.youtube.com') {
if (url.pathname === '/watch') youtubeId = url.searchParams.get('v') ?? '';
else if (/^\/(shorts|embed|live)\//.test(url.pathname)) {
youtubeId = url.pathname.split('/').filter(Boolean)[1] ?? '';
}
}
if (/^[A-Za-z0-9_-]{11}$/.test(youtubeId)) {
return { provider: 'youtube', id: youtubeId };
}
if (host === 'tiktok.com' || host === 'www.tiktok.com' || host === 'm.tiktok.com') {
const id = url.pathname.match(/\/video\/(\d+)/)?.[1] ?? '';
if (/^\d+$/.test(id)) return { provider: 'tiktok', id };
}
return null;
}
export function buildEmbedUrl(
provider: VideoProvider | '',
providerId: string,
timecodeSeconds: number | null
) {
if (provider === 'youtube' && /^[A-Za-z0-9_-]{11}$/.test(providerId)) {
const url = new URL(`https://www.youtube-nocookie.com/embed/${providerId}`);
if (timecodeSeconds && timecodeSeconds > 0)
url.searchParams.set('start', String(timecodeSeconds));
return url.href;
}
if (provider === 'tiktok' && /^\d+$/.test(providerId)) {
const url = new URL(`https://www.tiktok.com/player/v1/${providerId}`);
url.searchParams.set('autoplay', '0');
url.searchParams.set('controls', '1');
return url.href;
}
return '';
}
export async function extractMetadataFromHtml(
html: string,
url: string
): Promise<PresentationMetadata> {
const metadata = await scrape({ html, url });
return {
title: String(metadata.title ?? '').slice(0, 300),
description: String(metadata.description ?? '').slice(0, 1_000),
imageUrl: String(metadata.image ?? '').slice(0, 2_000),
siteName: String(metadata.publisher ?? new URL(url).hostname).slice(0, 200),
provider: '',
providerId: ''
};
}
async function providerMetadata(url: string, provider: VideoProvider) {
const endpoint =
provider === 'youtube'
? `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url)}`
: `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
const response = await requestPublic(endpoint, 'application/json', 256 * 1024);
if (!response.contentType.toLowerCase().includes('json')) throw new Error('FETCH_INVALID_TYPE');
const value = JSON.parse(response.data.toString('utf8')) as Record<string, unknown>;
return {
title: String(value.title ?? '').slice(0, 300),
description: '',
imageUrl: String(value.thumbnail_url ?? '').slice(0, 2_000),
siteName: String(value.provider_name ?? (provider === 'youtube' ? 'YouTube' : 'TikTok')).slice(
0,
200
)
};
}
export async function fetchPresentationMetadata(url: string): Promise<PresentationMetadata> {
const video = detectVideoProvider(url);
if (video) {
const metadata = await providerMetadata(url, video.provider);
return { ...metadata, provider: video.provider, providerId: video.id };
}
const response = await requestPublic(url, 'text/html,application/xhtml+xml', MAX_HTML_BYTES);
const contentType = response.contentType.toLowerCase();
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
throw new Error('FETCH_INVALID_TYPE');
}
return extractMetadataFromHtml(response.data.toString('utf8'), response.url);
}
export async function fetchPresentationImage(url: string) {
const response = await requestPublic(
url,
'image/jpeg,image/png,image/webp,image/gif',
MAX_IMAGE_BYTES
);
const mimeType = response.contentType.split(';')[0].trim().toLowerCase();
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(mimeType)) {
throw new Error('FETCH_INVALID_TYPE');
}
return { data: response.data, mimeType };
}
+251 -2
View File
@@ -9,10 +9,13 @@ import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createShow, createShow,
duplicatePieceUsage,
getPieceUsageContexts,
getShow, getShow,
listShowTemplates, listShowTemplates,
movePieceUsage, movePieceUsage,
moveShowSection, moveShowSection,
refreshPieceUsageFromOriginal,
removePieceUsage, removePieceUsage,
removeShowSection, removeShowSection,
updatePieceUsage, updatePieceUsage,
@@ -46,9 +49,16 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
moderation: edited.moderation, moderation: edited.moderation,
transition: edited.transition, transition: edited.transition,
presentation: edited.presentation, presentation: edited.presentation,
presentationMode: edited.presentationMode,
presentationSourceLabel: edited.presentationSourceLabel,
presentationSourceUrl: edited.presentationSourceUrl,
presentationSourceTimecodeSeconds: edited.presentationSourceTimecodeSeconds,
presentationTitle: edited.presentationTitle,
presentationDescription: edited.presentationDescription,
presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName,
ownNotes: edited.ownNotes, ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds, durationSeconds: edited.durationSeconds
status: edited.status
}; };
} }
@@ -153,6 +163,245 @@ describe('show builder data layer', () => {
}); });
}); });
it('derives sent history and current planning only from qualifying usages by original id', () => {
const completed = createShow({
title: 'Abgeschlossene Sendung',
startsAtUtc: '2027-01-10T19:00:00Z',
templateId: null
});
const completedSection = addShowSection(completed.id, completed.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const done = addPieceUsage(completed.id, completedSection.revision, {
pieceId: 'piece-console',
sectionId: completedSection.id
});
const skipped = addPieceUsage(completed.id, done.revision, {
pieceId: 'piece-effects',
sectionId: completedSection.id
});
const reserve = addPieceUsage(completed.id, skipped.revision, {
pieceId: 'piece-pixels',
sectionId: null
});
getDatabase()
.prepare("UPDATE piece_usages SET status = 'done' WHERE id IN (?, ?)")
.run(done.id, reserve.id);
getDatabase()
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
.run(skipped.id);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id);
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
const activeSection = addShowSection(active.id, active.revision, {
name: 'Plan',
categoryId: null,
explicitDurationSeconds: null
});
const planned = addPieceUsage(active.id, activeSection.revision, {
pieceId: 'piece-console',
sectionId: activeSection.id
});
const plannedReserve = addPieceUsage(active.id, planned.revision, {
pieceId: 'piece-effects',
sectionId: null
});
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(planned.id);
let contexts = getPieceUsageContexts(
['piece-console', 'piece-effects', 'piece-pixels'],
active.id
);
expect(contexts['piece-console']).toMatchObject({
isPlannedInCurrentShow: true,
sentHistory: [
{
usageId: done.id,
showId: completed.id,
showTitle: 'Abgeschlossene Sendung',
usedAtUtc: '2027-01-10T19:00:00.000Z'
}
]
});
expect(contexts['piece-effects']).toEqual({
isPlannedInActiveShow: true,
isPlannedInCurrentShow: true,
sentHistory: []
});
expect(contexts['piece-pixels']).toEqual({
isPlannedInActiveShow: true,
isPlannedInCurrentShow: false,
sentHistory: []
});
removePieceUsage(active.id, plannedReserve.id, plannedReserve.revision);
contexts = getPieceUsageContexts(['piece-effects'], active.id);
expect(contexts['piece-effects'].isPlannedInCurrentShow).toBe(false);
});
it('duplicates an adjusted usage directly after its source without live measurements', () => {
const created = createShow({
title: 'Einplanungsduplikat',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const usage = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const source = getShow(created.id)!.sections[0].usages[0];
const adjusted = updatePieceUsage(
created.id,
usage.id,
usage.revision,
editedUsage(source, {
title: 'Angepasste Einplanung',
durationSeconds: 777,
intro: 'Eigene Einleitung',
research: 'Eigene Recherche',
moderation: 'Eigene Moderation',
transition: 'Eigene Überleitung',
presentation: 'Eigene Präsentation',
ownNotes: 'Eigene Notizen'
})
);
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
getDatabase()
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc, ended_at_utc)
VALUES ('source-run', ?, ?, '2027-01-01T10:00:00.000Z', '2027-01-01T10:10:00.000Z')`
)
.run(created.id, usage.id);
const pieceCountBefore = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const duplicate = duplicatePieceUsage(created.id, usage.id, adjusted.revision);
const usages = getShow(created.id)!.sections[0].usages;
expect(usages.map((item) => item.id)).toEqual([usage.id, duplicate.id]);
expect(usages[1]).toMatchObject({
originalPieceId: 'piece-console',
status: 'open',
durationSeconds: 777,
title: 'Angepasste Einplanung',
intro: 'Eigene Einleitung',
research: 'Eigene Recherche',
moderation: 'Eigene Moderation',
transition: 'Eigene Überleitung',
presentation: 'Eigene Präsentation',
ownNotes: 'Eigene Notizen'
});
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get(duplicate.id)
).toEqual({ count: 0 });
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(pieceCountBefore);
getDatabase()
.prepare(
`UPDATE pieces SET title = 'Aktuelles Original', duration_seconds = 420,
intro = 'Neue Einleitung', research_text = 'Neue Recherche',
moderation_notes = 'Neue Moderation', transition_text = 'Neue Überleitung',
presentation_text = 'Neue Präsentation' WHERE id = 'piece-console'`
)
.run();
const refreshed = refreshPieceUsageFromOriginal(created.id, usage.id, duplicate.revision);
const refreshedUsages = getShow(created.id)!.sections[0].usages;
expect(refreshedUsages[0]).toMatchObject({
title: 'Aktuelles Original',
durationSeconds: 420,
intro: 'Neue Einleitung',
research: 'Neue Recherche',
moderation: 'Neue Moderation',
transition: 'Neue Überleitung',
presentation: 'Neue Präsentation',
ownNotes: 'Eigene Notizen',
status: 'done'
});
expect(refreshedUsages[1]).toMatchObject({
title: 'Angepasste Einplanung',
durationSeconds: 777,
status: 'open'
});
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get(usage.id)
).toEqual({ count: 1 });
expect(() => duplicatePieceUsage(created.id, usage.id, adjusted.revision)).toThrow('CONFLICT');
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
getDatabase()
.prepare('UPDATE piece_usages SET original_piece_id = NULL WHERE id = ?')
.run(duplicate.id);
expect(() =>
refreshPieceUsageFromOriginal(created.id, duplicate.id, refreshed.revision)
).toThrow('ORIGINAL_NOT_FOUND');
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
expect(getShow(created.id)!.sections[0].usages).toHaveLength(2);
});
it('snapshots presentation settings until an explicit refresh and copies them on duplication', () => {
getDatabase()
.prepare(
`UPDATE piece_sources SET label = 'YouTube', url = ?, timecode_seconds = 75
WHERE piece_id = 'piece-console' AND position = 0`
)
.run('https://youtu.be/dQw4w9WgXcQ');
getDatabase()
.prepare(
`UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0,
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
)
.run();
const show = createShow({ title: 'Snapshot-Test', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Videos',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75,
presentationTitle: 'Gespeicherte Vorschau'
});
getDatabase()
.prepare(
`UPDATE pieces SET presentation_title = 'Neuer Originaltitel' WHERE id = 'piece-console'`
)
.run();
expect(getShow(show.id)!.sections[0].usages[0].presentationTitle).toBe('Gespeicherte Vorschau');
const refreshed = refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.presentationTitle).toBe('Neuer Originaltitel');
duplicatePieceUsage(show.id, usage.id, refreshed.revision);
expect(getShow(show.id)!.sections[0].usages[1]).toMatchObject({
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationTitle: 'Neuer Originaltitel',
status: 'open'
});
});
it('persists normalized section and usage reordering, moving and removal', () => { it('persists normalized section and usage reordering, moving and removal', () => {
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null }); const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
const firstSection = addShowSection(created.id, created.revision, { const firstSection = addShowSection(created.id, created.revision, {
+392 -9
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const; export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const; export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
@@ -46,9 +47,32 @@ export type PieceUsage = {
moderation: string; moderation: string;
transition: string; transition: string;
presentation: string; presentation: string;
presentationMode: PresentationMode;
presentationSourceLabel: string;
presentationSourceUrl: string;
presentationSourceTimecodeSeconds: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProviderId: string;
ownNotes: string; ownNotes: string;
}; };
export type SentPieceUsage = {
usageId: string;
showId: string;
showTitle: string;
usedAtUtc: string;
};
export type PieceUsageContext = {
sentHistory: SentPieceUsage[];
isPlannedInActiveShow: boolean;
isPlannedInCurrentShow: boolean;
};
export type ShowSection = { export type ShowSection = {
id: string; id: string;
name: string; name: string;
@@ -110,9 +134,16 @@ export type PieceUsageUpdate = {
moderation: string; moderation: string;
transition: string; transition: string;
presentation: string; presentation: string;
presentationMode: PresentationMode;
presentationSourceLabel: string;
presentationSourceUrl: string;
presentationSourceTimecodeSeconds: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
status: UsageStatus;
}; };
export type MovePieceUsageInput = { export type MovePieceUsageInput = {
@@ -159,6 +190,16 @@ type UsageRow = {
moderation_snapshot: string; moderation_snapshot: string;
transition_snapshot: string; transition_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode;
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
presentation_title_meta_snapshot: string;
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: string;
presentation_provider_id_snapshot: string;
own_notes: string; own_notes: string;
}; };
@@ -191,6 +232,50 @@ function validTimestamp(value: string | null): string | null {
return date.toISOString(); return date.toISOString();
} }
function validPresentation(
mode: PresentationMode,
sourceLabel: string,
sourceUrl: string,
timecodeSeconds: number | null,
title: string,
description: string,
imageUrl: string,
siteName: string
) {
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) {
if (!value) continue;
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
} catch {
throw new Error('INVALID_PRESENTATION');
}
}
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if (
timecodeSeconds !== null &&
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
) {
throw new Error('INVALID_PRESENTATION');
}
const detected = detectVideoProvider(cleanUrl);
return {
mode,
sourceLabel: cleanText(sourceLabel, 200, 'presentation_source'),
sourceUrl: cleanUrl,
timecodeSeconds,
title: cleanText(title, 300, 'presentation_title'),
description: cleanText(description, 1_000, 'presentation_description'),
imageUrl: cleanImageUrl,
siteName: cleanText(siteName, 200, 'presentation_site'),
provider: detected?.provider ?? '',
providerId: detected?.id ?? ''
};
}
function requireRevision(expectedRevision: number): void { function requireRevision(expectedRevision: number): void {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION'); throw new Error('INVALID_REVISION');
@@ -296,6 +381,16 @@ function usageFromRow(row: UsageRow): PieceUsage {
moderation: row.moderation_snapshot, moderation: row.moderation_snapshot,
transition: row.transition_snapshot, transition: row.transition_snapshot,
presentation: row.presentation_snapshot, presentation: row.presentation_snapshot,
presentationMode: row.presentation_mode_snapshot,
presentationSourceLabel: row.presentation_source_label_snapshot,
presentationSourceUrl: row.presentation_source_url_snapshot,
presentationSourceTimecodeSeconds: row.presentation_source_timecode_snapshot,
presentationTitle: row.presentation_title_meta_snapshot,
presentationDescription: row.presentation_description_snapshot,
presentationImageUrl: row.presentation_image_url_snapshot,
presentationSiteName: row.presentation_site_name_snapshot,
presentationProvider: row.presentation_provider_snapshot,
presentationProviderId: row.presentation_provider_id_snapshot,
ownNotes: row.own_notes ownNotes: row.own_notes
}; };
} }
@@ -384,7 +479,12 @@ export function getShow(id: string): Show | null {
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot, u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
u.presentation_provider_snapshot, u.presentation_provider_id_snapshot, u.own_notes
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? ORDER BY s.position, u.position` WHERE s.show_id = ? ORDER BY s.position, u.position`
) )
@@ -430,6 +530,80 @@ export function getShow(id: string): Show | null {
}; };
} }
export function getPieceUsageContexts(
pieceIds: string[],
currentShowId?: string
): Record<string, PieceUsageContext> {
const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))];
const contexts = Object.fromEntries(
uniquePieceIds.map((pieceId) => [
pieceId,
{
sentHistory: [],
isPlannedInActiveShow: false,
isPlannedInCurrentShow: false
} satisfies PieceUsageContext
])
) as Record<string, PieceUsageContext>;
if (!uniquePieceIds.length) return contexts;
const database = getDatabase();
const history = database
.prepare(
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id,
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE u.original_piece_id IS NOT NULL AND u.status = 'done'
AND sh.status = 'completed' AND s.is_reserve_bank = 0
ORDER BY used_at_utc DESC, u.id DESC`
)
.all() as {
usage_id: string;
original_piece_id: string;
show_id: string;
show_title: string;
used_at_utc: string;
}[];
for (const usage of history) {
contexts[usage.original_piece_id]?.sentHistory.push({
usageId: usage.usage_id,
showId: usage.show_id,
showTitle: usage.show_title,
usedAtUtc: usage.used_at_utc
});
}
const plannedInActiveShows = database
.prepare(
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status != 'completed' AND u.original_piece_id IS NOT NULL`
)
.all() as { original_piece_id: string }[];
for (const usage of plannedInActiveShows) {
const context = contexts[usage.original_piece_id];
if (context) context.isPlannedInActiveShow = true;
}
if (currentShowId) {
const planned = database
.prepare(
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND u.original_piece_id IS NOT NULL`
)
.all(currentShowId) as { original_piece_id: string }[];
for (const usage of planned) {
const context = contexts[usage.original_piece_id];
if (context) context.isPlannedInCurrentShow = true;
}
}
return contexts;
}
export function createShow(input: CreateShowInput): CreatedMutationResult { export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase(); const database = getDatabase();
const id = randomUUID(); const id = randomUUID();
@@ -631,8 +805,15 @@ 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 title, duration_seconds, intro, research_text, moderation_notes, `SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
transition_text, presentation_text, status FROM pieces WHERE id = ?` p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?`
) )
.get(input.pieceId) as .get(input.pieceId) as
| { | {
@@ -643,6 +824,16 @@ export function addPieceUsage(
moderation_notes: string; moderation_notes: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_label: string;
presentation_source_url: string;
presentation_source_timecode: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
status: string; status: string;
} }
| undefined; | undefined;
@@ -659,8 +850,13 @@ export function addPieceUsage(
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve) moderation_snapshot, transition_snapshot, presentation_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)` presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -674,6 +870,16 @@ export function addPieceUsage(
piece.moderation_notes, piece.moderation_notes,
piece.transition_text, piece.transition_text,
piece.presentation_text, piece.presentation_text,
piece.presentation_mode,
piece.presentation_source_label,
piece.presentation_source_url,
piece.presentation_source_timecode,
piece.presentation_title,
piece.presentation_description,
piece.presentation_image_url,
piece.presentation_site_name,
piece.presentation_provider,
piece.presentation_provider_id,
input.sectionId === null ? 1 : 0 input.sectionId === null ? 1 : 0
); );
ids.splice(position, 0, id); ids.splice(position, 0, id);
@@ -682,6 +888,160 @@ export function addPieceUsage(
})(); })();
} }
export function duplicatePieceUsage(
showId: string,
usageId: string,
expectedRevision: number
): CreatedMutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const sourceSection = usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?`
)
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
is_reserve: number;
};
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(sourceSection.sectionId) as { id: string }[]
).map((row) => row.id);
const sourcePosition = ids.indexOf(usageId);
if (sourcePosition < 0) throw new Error('NOT_FOUND');
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
sourceSection.sectionId,
source.original_piece_id,
ids.length,
source.duration_seconds,
source.title_snapshot,
source.intro_snapshot,
source.research_snapshot,
source.moderation_snapshot,
source.transition_snapshot,
source.presentation_snapshot,
source.presentation_mode_snapshot,
source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot,
source.presentation_title_meta_snapshot,
source.presentation_description_snapshot,
source.presentation_image_url_snapshot,
source.presentation_site_name_snapshot,
source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot,
source.own_notes,
source.is_reserve
);
ids.splice(sourcePosition + 1, 0, id);
normalizePositions(database, 'piece_usages', ids);
return { ...result, id };
})();
}
export function refreshPieceUsageFromOriginal(
showId: string,
usageId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position
WHERE u.id = ?`
)
.get(usageId) as
| {
title: string;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_label: string;
presentation_source_url: string;
presentation_source_timecode: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
}
| undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?`
)
.run(
source.title,
source.duration_seconds,
source.intro,
source.research_text,
source.moderation_notes,
source.transition_text,
source.presentation_text,
source.presentation_mode,
source.presentation_source_label,
source.presentation_source_url,
source.presentation_source_timecode,
source.presentation_title,
source.presentation_description,
source.presentation_image_url,
source.presentation_site_name,
source.presentation_provider,
source.presentation_provider_id,
usageId
);
return result;
})();
}
export function updatePieceUsage( export function updatePieceUsage(
showId: string, showId: string,
usageId: string, usageId: string,
@@ -690,7 +1050,6 @@ export function updatePieceUsage(
): MutationResult { ): MutationResult {
const title = cleanText(input.title, 200, 'title', true); const title = cleanText(input.title, 200, 'title', true);
const duration = validDuration(input.durationSeconds)!; const duration = validDuration(input.durationSeconds)!;
if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS');
const values = { const values = {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'), research: cleanText(input.research, 50_000, 'research'),
@@ -699,6 +1058,16 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'), presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes') ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
}; };
const presentation = validPresentation(
input.presentationMode,
input.presentationSourceLabel,
input.presentationSourceUrl,
input.presentationSourceTimecodeSeconds,
input.presentationTitle,
input.presentationDescription,
input.presentationImageUrl,
input.presentationSiteName
);
const database = getDatabase(); const database = getDatabase();
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
@@ -707,7 +1076,12 @@ export function updatePieceUsage(
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?` presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?`
) )
.run( .run(
title, title,
@@ -716,9 +1090,18 @@ export function updatePieceUsage(
values.moderation, values.moderation,
values.transition, values.transition,
values.presentation, values.presentation,
presentation.mode,
presentation.sourceLabel,
presentation.sourceUrl,
presentation.timecodeSeconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName,
presentation.provider,
presentation.providerId,
values.ownNotes, values.ownNotes,
duration, duration,
input.status,
usageId usageId
); );
return result; return result;
+3 -2
View File
@@ -12,9 +12,9 @@ export const actions = {
capture: async ({ locals, request }) => { capture: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string;
try { try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || '')); id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
} catch (cause) { } catch (cause) {
const code = cause instanceof Error ? cause.message : ''; const code = cause instanceof Error ? cause.message : '';
return fail(400, { return fail(400, {
@@ -26,5 +26,6 @@ export const actions = {
: 'Ein Titel ist erforderlich.' : 'Ein Titel ist erforderlich.'
}); });
} }
redirect(303, `/fundus/${id}`);
} }
} satisfies Actions; } satisfies Actions;
+18 -13
View File
@@ -10,6 +10,7 @@ import {
setPieceArchived, setPieceArchived,
type PieceTemplateId type PieceTemplateId
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js';
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -18,21 +19,25 @@ export const load: PageServerLoad = ({ locals, url }) => {
formatId: url.searchParams.get('format') ?? '', formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '', topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '', categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '' status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
}; };
const pieces = listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
return { return {
pieces: listPieces({ pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
}),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
templates: pieceTemplates, templates: pieceTemplates,
filters filters
+75 -6
View File
@@ -1,9 +1,48 @@
<script lang="ts"> <script lang="ts">
import { goto, replaceState } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import { formatClock } from '#lib/format.js'; import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0) &&
(showPlannedPieces || !piece.isPlannedInActiveShow)
)
);
onMount(() => {
const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams;
showUsedPieces = search.get('includeUsed') === '1';
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
const url = new URL(page.url.href);
if (checked) url.searchParams.set(name, '1');
else url.searchParams.delete(name);
// This URL is cloned from the current page; only same-route filter parameters change.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
}
async function resetFilters() {
await goto(resolve('/fundus'), { invalidateAll: true });
showUsedPieces = false;
showPlannedPieces = false;
}
const statusLabel = { const statusLabel = {
ready: 'Sendefertig', ready: 'Sendefertig',
preparing: 'In Vorbereitung', preparing: 'In Vorbereitung',
@@ -20,8 +59,8 @@
<h1>Fundus</h1> <h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div> </div>
<div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}> <div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span> <strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
@@ -41,7 +80,12 @@
</div> </div>
</details> </details>
<form class="library-filters" method="GET" aria-label="Fundus durchsuchen und filtern"> <form
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<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"
@@ -84,12 +128,36 @@
></label ></label
> >
<button class="secondary-button" type="submit">Anwenden</button> <button class="secondary-button" type="submit">Anwenden</button>
<a class="text-link" href={resolve('/fundus')}>Zurücksetzen</a> <button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</form> </form>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="library-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
form="library-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
{#if data.pieces.length} {#if visiblePieces.length}
<div class="piece-grid"> <div class="piece-grid">
{#each data.pieces as piece (piece.id)} {#each visiblePieces as piece (piece.id)}
<article <article
class="piece-card" class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`} style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
@@ -104,6 +172,7 @@
<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>
<PieceUsageHistory history={piece.sentHistory} />
</div> </div>
<div class="piece-classification"> <div class="piece-classification">
<span class="category-label" <span class="category-label"
+45
View File
@@ -6,6 +6,7 @@ import {
duplicatePiece, duplicatePiece,
getPiece, getPiece,
getTaxonomy, getTaxonomy,
loadPiecePresentationMetadata,
savePiece, savePiece,
setPieceArchived, setPieceArchived,
uploadPieceImage, uploadPieceImage,
@@ -65,6 +66,20 @@ function pieceInput(data: FormData): PieceInput {
moderationNotes: text(data, 'moderationNotes'), moderationNotes: text(data, 'moderationNotes'),
transitionText: text(data, 'transitionText'), transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'), presentationText: text(data, 'presentationText'),
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
presentationSourcePosition: text(data, 'presentationSourcePosition')
? Number(data.get('presentationSourcePosition'))
: null,
presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'),
presentationProvider: text(data, 'presentationProvider'),
presentationProviderId: text(data, 'presentationProviderId'),
presentationTitleManual: text(data, 'presentationTitleManual') === '1',
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
presentationImageManual: text(data, 'presentationImageManual') === '1',
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
topicIds: data.getAll('topicIds').map(String), topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String), categoryIds: data.getAll('categoryIds').map(String),
sources sources
@@ -87,6 +102,11 @@ function saveError(cause: unknown) {
status: 400, status: 400,
error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.' error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.'
}; };
if (code === 'INVALID_PRESENTATION')
return {
status: 400,
error: 'Für Linkvorschau und Video muss eine vorhandene gültige Quelle ausgewählt sein.'
};
if (code.startsWith('INVALID_')) if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return { return {
@@ -116,6 +136,31 @@ export const actions = {
return fail(problem.status, { intent: 'save', ...problem }); return fail(problem.status, { intent: 'save', ...problem });
} }
}, },
loadPreview: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
await loadPiecePresentationMetadata(params.id, Number(data.get('revision')));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'FETCH_BLOCKED') {
return fail(400, {
intent: 'loadPreview',
error: 'Interne oder private Netzwerkziele dürfen nicht abgerufen werden.'
});
}
if (code.startsWith('FETCH_')) {
return fail(400, {
intent: 'loadPreview',
error:
'Die Vorschau konnte nicht sicher geladen werden. Die manuellen Angaben bleiben erhalten.'
});
}
const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem });
}
redirect(303, `/fundus/${params.id}`);
},
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();
+556 -177
View File
@@ -2,10 +2,13 @@
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte'; import { onDestroy, tick, untrack } from 'svelte';
import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte'; import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
type ContextPanel = 'sources' | 'classification' | 'presentation';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece }))); let draft = $state(untrack(() => ({ ...data.piece })));
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2)))); let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
@@ -14,7 +17,12 @@
let rendered = $state(untrack(() => ({ ...data.rendered }))); let rendered = $state(untrack(() => ({ ...data.rendered })));
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle'); let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert'); let saveMessage = $state('Alle Änderungen gespeichert');
let classificationEditing = $state(false);
let presentationEditing = $state(false);
let mobileContext = $state<ContextPanel | null>(null);
let sourceImageUnavailable = $state(false);
let editorForm: HTMLFormElement; let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement; let saveButton: HTMLButtonElement;
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0; let generation = 0;
@@ -25,8 +33,27 @@
saveState === 'error' || saveState === 'error' ||
saveState === 'conflict' saveState === 'conflict'
); );
let featuredSource = $derived(
draft.presentationSourcePosition === null
? (sources[0] ?? null)
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
);
let sourcePreviewImage = $derived(
draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: data.piece.media[0]
? `/media/${data.piece.media[0].id}`
: ''
);
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
let contextTrigger: HTMLElement | null = null;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onDestroy(() => clearTimeout(timer)); onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => { beforeNavigate((navigation) => {
@@ -42,7 +69,17 @@
if (!saving) submitSave(); if (!saving) submitSave();
}); });
function scheduleSave() { function scheduleSave(event?: Event) {
const control = event?.target;
if (event?.type === 'change' && !(control instanceof HTMLSelectElement)) return;
if (
control instanceof HTMLInputElement &&
control.name === 'presentationMode' &&
control.value !== 'text' &&
draft.presentationSourcePosition === null
) {
return;
}
generation += 1; generation += 1;
saveState = 'dirty'; saveState = 'dirty';
saveMessage = 'Ungespeicherte Änderungen'; saveMessage = 'Ungespeicherte Änderungen';
@@ -62,9 +99,43 @@
function removeSource(index: number) { function removeSource(index: number) {
sources.splice(index, 1); sources.splice(index, 1);
if (draft.presentationSourcePosition === index) draft.presentationSourcePosition = null;
else if (
draft.presentationSourcePosition !== null &&
draft.presentationSourcePosition > index
) {
draft.presentationSourcePosition -= 1;
}
scheduleSave(); scheduleSave();
} }
function presentationSourceChanged() {
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
}
async function openContext(event: MouseEvent, panel: ContextPanel) {
contextTrigger = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
mobileContext = panel;
await tick();
document.querySelector<HTMLElement>(`[data-context-close="${panel}"]`)?.focus();
}
function closeContext() {
mobileContext = null;
contextTrigger?.focus();
contextTrigger = null;
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && mobileContext) closeContext();
}
const enhanceSave: SubmitFunction = () => { const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation; const submittedGeneration = generation;
saving = true; saving = true;
@@ -105,22 +176,9 @@
</script> </script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head> <svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:window onkeydown={handleEscape} />
<div class="page-wrap editor-page"> <div class="page-wrap editor-page worksheet-page">
<header class="editor-header">
<div>
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form <form
@@ -128,183 +186,504 @@
use:enhance={enhanceSave} use:enhance={enhanceSave}
method="POST" method="POST"
action="?/save" action="?/save"
class="piece-editor" class="piece-editor worksheet-editor"
oninput={scheduleSave} oninput={scheduleSave}
onchange={scheduleSave} onchange={scheduleSave}
> >
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
<section class="editor-section editor-basics"> <header class="worksheet-header">
<div class="section-title"> <div class="worksheet-heading">
<p class="eyebrow">Grundlagen</p> <a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h2>Worum geht es?</h2> <h1 class="sr-only">{draft.title}</h1>
<label class="worksheet-title">
<span class="sr-only">Titel</span>
<textarea
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}></textarea>
</label>
</div> </div>
<div class="editor-fields two-columns"> <div
<label class="wide-field" class:problem={saveState === 'error' || saveState === 'conflict'}
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label class="save-indicator"
> role="status"
<label aria-live="polite"
>Status<select name="status" bind:value={draft.status} >
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option <i></i><span>{saveMessage}</span>
><option value="ready">Sendefertig</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div> </div>
</section> </header>
<section class="editor-section taxonomy-editor"> <nav class="mobile-editor-tools" aria-label="Piece-Werkzeuge">
<div class="section-title"> <button
<p class="eyebrow">Einordnung</p> type="button"
<h2>Themen und Abschnitte</h2> aria-expanded={mobileContext === 'sources'}
</div> onclick={(event) => openContext(event, 'sources')}>Quellen</button
<fieldset> >
<legend>Themen</legend> <button
<div class="choice-grid"> type="button"
{#each data.taxonomy.topics as item (item.id)}<label aria-expanded={mobileContext === 'classification'}
><input onclick={(event) => openContext(event, 'classification')}>Einordnung</button
type="checkbox" >
name="topicIds" <button
value={item.id} type="button"
bind:group={draft.topicIds} aria-expanded={mobileContext === 'presentation'}
/>{item.name}</label onclick={(event) => openContext(event, 'presentation')}>Präsentation</button
>{/each} >
</div> <a href="#piece-media">Bilder</a>
</fieldset> </nav>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</section>
<section class="editor-section markdown-section"> <div class="editor-workspace">
<div class="section-title"> <main class="editorial-worksheet" aria-label="Redaktionelles Arbeitsblatt">
<p class="eyebrow">Redaktion</p> <section
<h2>Getrennte Arbeitsbereiche</h2> class:has-image={sourcePreviewImage && !sourceImageUnavailable}
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small> class="source-feature"
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
> >
<label class="private-field" {#if sourcePreviewImage && !sourceImageUnavailable}
><span>Private Recherche</span><textarea <img src={sourcePreviewImage} alt="" onerror={() => (sourceImageUnavailable = true)} />
name="researchText" {/if}
rows="7" <div>
maxlength="50000" <p class="eyebrow">Fundstück</p>
bind:value={draft.researchText}></textarea> {#if featuredSource}
<MarkdownPreview html={rendered.research} /></label <h2>{draft.presentationTitle || featuredSource.label || 'Quelle'}</h2>
> {#if draft.presentationDescription}<p>{draft.presentationDescription}</p>{/if}
<label <small>{featuredSource.label || featuredSource.url}</small>
><span>Moderationsstichpunkte</span><textarea <!-- The URL is a server-validated external HTTP(S) source. -->
name="moderationNotes" <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
rows="7" <a href={featuredSource.url} target="_blank" rel="noopener noreferrer"
maxlength="50000" >Original öffnen ↗</a
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
</div>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
</div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
> >
</div>{/each} {:else}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if} <h2>Noch keine Quelle</h2>
</section> <p>Eine Quelle lässt sich ergänzen, ohne den Schreibstand zu verlassen.</p>
<button
class="text-button"
type="button"
onclick={(event) => openContext(event, 'sources')}>Quelle ergänzen</button
>
{/if}
</div>
</section>
<section class="writing-sheet">
<header class="writing-sheet-heading">
<div>
<p class="eyebrow">Arbeitsstand</p>
<h2>Gedanken und Sendungstext</h2>
</div>
<small>Markdown wird beim Speichern sicher formatiert.</small>
</header>
<EditorialMarkdownField
name="researchText"
label="Eigene Gedanken & Recherche"
inputLabel="Private Recherche"
hint="Nur intern"
emptyText="Beobachtungen, Links und offene Fragen festhalten"
bind:value={draft.researchText}
html={rendered.research}
privateField
rows={9}
/>
<div class="script-heading">
<p class="eyebrow">Für die Sendung</p>
<h2>Sendungstext</h2>
<p>Einleitung, Moderation und Übergang bleiben getrennt gespeichert.</p>
</div>
<div class="script-sections">
<EditorialMarkdownField
name="intro"
label="Einleitung"
emptyText="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
html={rendered.intro}
/>
<EditorialMarkdownField
name="moderationNotes"
label="Moderationsstichpunkte"
emptyText="Die wichtigsten Punkte für die Moderation notieren"
bind:value={draft.moderationNotes}
html={rendered.moderation}
/>
<EditorialMarkdownField
name="transitionText"
label="Überleitung"
emptyText="Den Übergang zum nächsten Thema vorbereiten"
bind:value={draft.transitionText}
html={rendered.transition}
/>
</div>
</section>
</main>
<aside
class:context-open={mobileContext}
class="editor-context"
aria-label="Werkzeuge zum Piece"
>
<section
class:mobile-active={mobileContext === 'sources'}
class="context-card source-editor"
aria-labelledby="source-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Fundstück</p>
<h2 id="source-context-title">Quellen</h2>
</div>
<button
class="context-close"
data-context-close="sources"
type="button"
aria-label="Quellen schließen"
onclick={closeContext}>×</button
>
</header>
{#if sources.length}
<div class="source-list">
{#each sources as source, index (index)}
<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
bind:value={source.label}
/></label
>
<label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
>
<label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
>
<button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>
{/each}
</div>
{:else}
<p class="context-empty">Noch keine Quelle hinterlegt.</p>
{/if}
<button class="secondary-button context-wide-action" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</section>
<section
class:mobile-active={mobileContext === 'classification'}
class="context-card classification-editor"
aria-labelledby="classification-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Überblick</p>
<h2 id="classification-context-title">Einordnung</h2>
</div>
<button
class="context-close"
data-context-close="classification"
type="button"
aria-label="Einordnung schließen"
onclick={closeContext}>×</button
>
</header>
<div class="classification-summary">
<strong>{statusLabels[draft.status as keyof typeof statusLabels]}</strong>
<span>{durationMinutes || 0} Min.</span>
<span
>{data.taxonomy.formats.find((item) => item.id === draft.formatId)?.name ??
'Ohne Format'}</span
>
</div>
{#if draft.summary}<p class="context-summary-copy">{draft.summary}</p>{/if}
<div class="context-tags">
{#each data.taxonomy.topics.filter( (item) => draft.topicIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
{#each data.taxonomy.categories.filter( (item) => draft.categoryIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
</div>
<button
class="text-button context-edit-toggle"
type="button"
aria-expanded={classificationEditing}
onclick={() => (classificationEditing = !classificationEditing)}
>{classificationEditing ? 'Einordnung schließen' : 'Einordnung bearbeiten'}</button
>
<div class="context-fields" hidden={!classificationEditing}>
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing"
>In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
{#each data.taxonomy.topics as item (item.id)}<label
><input
type="checkbox"
name="topicIds"
value={item.id}
bind:group={draft.topicIds}
/>{item.name}</label
>{/each}
</div>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</div>
</section>
<section
class:mobile-active={mobileContext === 'presentation'}
class="context-card presentation-editor"
aria-labelledby="presentation-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Publikum</p>
<h2 id="presentation-context-title">Präsentation</h2>
</div>
<button
class="context-close"
data-context-close="presentation"
type="button"
aria-label="Präsentation schließen"
onclick={closeContext}>×</button
>
</header>
<div class="audience-preview">
<span
>{draft.presentationMode === 'text'
? 'Text'
: draft.presentationMode === 'link'
? 'Linkvorschau'
: 'Video'}</span
>
<strong>{draft.presentationTitle || draft.title}</strong>
{#if draft.presentationMode === 'text' && rendered.presentation}<MarkdownPreview
html={rendered.presentation}
/>{:else if draft.presentationDescription}<p>
{draft.presentationDescription}
</p>{:else}<p>Noch keine Vorschauangaben.</p>{/if}
</div>
<div class="presentation-card-actions">
<button
class="text-button"
type="button"
aria-expanded={presentationEditing}
onclick={() => (presentationEditing = !presentationEditing)}
>{presentationEditing ? 'Einstellungen schließen' : 'Präsentation bearbeiten'}</button
>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">Präsentation testen</a
>
</div>
<div class="presentation-controls" hidden={!presentationEditing}>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>{/each}
</div>
{#if draft.presentationMode === 'text'}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
<EditorialMarkdownField
name="presentationText"
label="Präsentationstext"
emptyText="Text für die Publikumsausgabe schreiben"
bind:value={draft.presentationText}
html={rendered.presentation}
rows={8}
/>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label
>Quelle<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
><option value={null}>Quelle auswählen</option
>{#each sources as source, index (index)}{#if source.url}<option value={index}
>{source.label || source.url}</option
>{/if}{/each}</select
></label
>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}</small
>
</div>
</div>
{/if}
</div>
<input
type="hidden"
name="presentationTitleManual"
value={draft.presentationTitleManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationDescriptionManual"
value={draft.presentationDescriptionManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationImageManual"
value={draft.presentationImageManual ? '1' : '0'}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
<section class="context-card asset-summary">
<div>
<p class="eyebrow">Material</p>
<h2>Bilder</h2>
<p>
{data.piece.media.length
? `${data.piece.media.length} gespeichert`
: 'Noch keine Bilder'}
</p>
</div>
<a class="text-link" href="#piece-media">Bilder verwalten ↓</a>
</section>
</aside>
</div>
<div class="editor-savebar"> <div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span <span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span>
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving} <button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
>Jetzt speichern</button >Jetzt speichern</button
> >
</div> </div>
</form> </form>
<section class="editor-section media-editor"> <form
bind:this={previewForm}
id="preview-load-form"
method="POST"
action="?/loadPreview"
use:enhance
>
<input type="hidden" name="revision" value={revision} />
</form>
<section id="piece-media" class="editor-section media-editor supporting-editor-section">
<div class="section-title"> <div class="section-title">
<p class="eyebrow">Private Dateien</p> <p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2> <h2>Bilder</h2>
@@ -0,0 +1,44 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getPiece } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
export const load: PageServerLoad = ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
const source =
piece.presentationSourcePosition === null
? null
: (piece.sources[piece.presentationSourcePosition] ?? null);
const content: PresentationContent & {
sources: typeof piece.sources;
images: { id: string; altText: string }[];
} = {
mode: piece.presentationMode,
title: piece.title,
html: renderMarkdown(piece.presentationText),
source,
previewTitle: piece.presentationTitle,
description: piece.presentationDescription,
imageUrl: piece.presentationImageUrl,
siteName: piece.presentationSiteName,
provider:
piece.presentationProvider === 'youtube' || piece.presentationProvider === 'tiktok'
? piece.presentationProvider
: '',
providerId: piece.presentationProviderId,
embedUrl: buildEmbedUrl(
piece.presentationProvider === 'youtube' || piece.presentationProvider === 'tiktok'
? piece.presentationProvider
: '',
piece.presentationProviderId,
source?.timecodeSeconds ?? null
),
sources: piece.sources,
images: piece.media.map((item) => ({ id: item.id, altText: item.altText }))
};
return { pieceId: piece.id, content };
};
@@ -0,0 +1,21 @@
<script lang="ts">
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>Präsentation testen | Sendewerk</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class="heinzlive-output presentation-test-output">
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay
content={data.content}
imageSrc={data.content.mode === 'text'
? '/media'
: `/fundus/${data.pieceId}/praesentation/preview-image`}
/>
</main>
@@ -0,0 +1,26 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getPiece } from '#lib/server/content.js';
import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar');
try {
const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'none'; sandbox"
}
});
} catch {
error(404, 'Vorschaubild nicht verfügbar');
}
};
+10 -21
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -40,30 +41,18 @@
</svelte:head> </svelte:head>
<main class="heinzlive-output"> <main class="heinzlive-output">
<div class="output-connection" class:problem={connection === 'interrupted'} role="status"> <div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'} <span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div> </div>
{#if view.content} {#if view.content}
<article class="output-content"> {#key view.revision}
<p class="output-kicker">heinzlive</p> <PresentationDisplay
<h1>{view.content.title}</h1> content={view.content}
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. --> imageSrc={view.content.mode === 'text'
<!-- eslint-disable-next-line svelte/no-at-html-tags --> ? `/heinzlive/${page.params.token}/media`
<div class="output-copy">{@html view.content.html}</div> : `/heinzlive/${page.params.token}/preview-image`}
{#if view.content.images.length}<div class="output-images"> />
{#each view.content.images as image (image.id)} {/key}
<img src={`/heinzlive/${page.params.token}/media/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if view.content.sources.length}<footer class="output-sources">
<span>Quellen</span>
{#each view.content.sources as source, index (`${index}-${source.url}`)}
<!-- Source URLs are validated as absolute HTTP(S) URLs when the Piece is saved. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else} {:else}
<section class="output-idle"> <section class="output-idle">
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div> <div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationPreviewImage } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const image = await getPresentationPreviewImage(params.token);
if (!image) error(404, 'Vorschaubild nicht verfügbar');
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'none'; sandbox"
}
});
};
+25 -13
View File
@@ -73,6 +73,10 @@
persistSettings(); persistSettings();
} }
function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => { const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
const command = formElement.elements.namedItem('commandId'); const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) { if (command instanceof HTMLInputElement) {
@@ -147,7 +151,7 @@
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if} {#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
<section class="live-toolbar" aria-label="Live-Sitzung"> <section class="live-toolbar" aria-label="Live-Sitzung">
<form method="POST" action="?/start" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('start')} 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} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
@@ -155,7 +159,7 @@
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'} {data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
</button> </button>
</form> </form>
<form method="POST" action="?/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">Präsentationsfenster öffnen</button>
</form> </form>
@@ -200,13 +204,13 @@
</section>{/if} </section>{/if}
<div class="live-current-actions"> <div class="live-current-actions">
<form method="POST" action="?/back" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('back')} 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} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zurück</button> <button class="secondary-button" type="submit">Zurück</button>
</form> </form>
<form method="POST" action="?/present" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('present')} 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="usageId" value={data.live.current.id} /> <input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -217,13 +221,13 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </form>
<form method="POST" action="?/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} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Überspringen</button> <button class="text-button" type="submit">Überspringen</button>
</form> </form>
<form method="POST" action="?/complete" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('complete')} 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} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
@@ -240,7 +244,7 @@
</p> </p>
{#if data.live.completedCount}<form {#if data.live.completedCount}<form
method="POST" method="POST"
action="?/back" action={liveAction('back')}
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
> >
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
@@ -285,7 +289,11 @@
</div> </div>
<div class="live-run-actions"> <div class="live-run-actions">
{#if usage.status === 'open'} {#if usage.status === 'open'}
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}> <form
method="POST"
action={liveAction('moveUsage')}
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="showRevision" value={data.live.show.revision} /> <input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
@@ -298,7 +306,11 @@
aria-label={`${usage.title} nach oben`}>↑</button aria-label={`${usage.title} nach oben`}>↑</button
> >
</form> </form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}> <form
method="POST"
action={liveAction('moveUsage')}
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="showRevision" value={data.live.show.revision} /> <input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
@@ -312,7 +324,7 @@
> >
</form> </form>
{/if} {/if}
<form method="POST" action="?/present" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('present')} 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="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -335,7 +347,7 @@
>{/if} >{/if}
</div> </div>
<div class="live-run-actions"> <div class="live-run-actions">
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('moveUsage')} 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="showRevision" value={data.live.show.revision} /> <input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
@@ -347,7 +359,7 @@
aria-label={`${usage.title} in Reserve nach oben`}>↑</button aria-label={`${usage.title} in Reserve nach oben`}>↑</button
> >
</form> </form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}> <form method="POST" action={liveAction('moveUsage')} 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="showRevision" value={data.live.show.revision} /> <input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
@@ -361,7 +373,7 @@
</form> </form>
{#if data.live.current || data.live.sections[0]}<form {#if data.live.current || data.live.sections[0]}<form
method="POST" method="POST"
action="?/moveUsage" action={liveAction('moveUsage')}
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
> >
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
+43 -8
View File
@@ -5,16 +5,19 @@ import { getTaxonomy, listPieces } from '#lib/server/content.js';
import { import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
duplicatePieceUsage,
getShow, getShow,
getPieceUsageContexts,
movePieceUsage, movePieceUsage,
moveShowSection, moveShowSection,
refreshPieceUsageFromOriginal,
removePieceUsage, removePieceUsage,
removeShowSection, removeShowSection,
saveShowMetadata, saveShowMetadata,
updatePieceUsage, updatePieceUsage,
updateShowSection, updateShowSection,
type EditableShowStatus, type EditableShowStatus,
type UsageStatus type PieceUsageUpdate
} from '#lib/server/shows.js'; } from '#lib/server/shows.js';
function text(data: FormData, name: string) { function text(data: FormData, name: string) {
@@ -84,6 +87,8 @@ function actionError(cause: unknown) {
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' }; return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY') if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' }; return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
@@ -104,18 +109,25 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user); requireUser(locals.user);
const show = getShow(params.id); const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden'); if (!show) error(404, 'Sendung nicht gefunden');
const pieces = listPieces({
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
});
const usageContexts = getPieceUsageContexts(
pieces.map((piece) => piece.id),
show.id
);
return { return {
show, show,
startsAtLocal: berlinInput(show.startsAtUtc), startsAtLocal: berlinInput(show.startsAtUtc),
pieces: listPieces({ pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
}),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
filters: { filters: {
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '' categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
} }
}; };
}; };
@@ -198,10 +210,33 @@ export const actions = {
moderation: text(data, 'moderation'), moderation: text(data, 'moderation'),
transition: text(data, 'transition'), transition: text(data, 'transition'),
presentation: text(data, 'presentation'), presentation: text(data, 'presentation'),
status: text(data, 'status') as UsageStatus presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
presentationSourceLabel: text(data, 'presentationSourceLabel'),
presentationSourceUrl: text(data, 'presentationSourceUrl'),
presentationSourceTimecodeSeconds: text(data, 'presentationSourceTimecodeSeconds')
? Number(data.get('presentationSourceTimecodeSeconds'))
: null,
presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName')
}) })
); );
}, },
duplicateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('duplicateUsage', () =>
duplicatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
refreshUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('refreshUsage', () =>
refreshPieceUsageFromOriginal(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
removeUsage: async ({ locals, params, request }) => { removeUsage: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+84 -106
View File
@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { replaceState } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -23,9 +28,27 @@
let dropSectionId = $state(''); let dropSectionId = $state('');
let dropPosition = $state(0); let dropPosition = $state(0);
let copyMessage = $state(''); let copyMessage = $state('');
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0) &&
(showPlannedPieces || !piece.isPlannedInCurrentShow)
)
);
let plannedCount = $derived( let plannedCount = $derived(
data.show.sections.reduce((total, section) => total + section.usages.length, 0) data.show.sections.reduce((total, section) => total + section.usages.length, 0)
); );
onMount(() => {
const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams;
showUsedPieces = search.get('includeUsed') === '1';
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function berlinDate(value: string | null) { function berlinDate(value: string | null) {
if (!value) return 'Termin offen'; if (!value) return 'Termin offen';
@@ -36,8 +59,15 @@
}).format(new Date(value)); }).format(new Date(value));
} }
function durationInMinutes(seconds: number) { function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
return Number((seconds / 60).toFixed(2)); if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
const url = new URL(page.url.href);
if (checked) url.searchParams.set(name, '1');
else url.searchParams.delete(name);
// This URL is cloned from the current page; only same-route filter parameters change.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
} }
function startDrag(kind: 'piece' | 'section' | 'usage', id: string, event: DragEvent) { function startDrag(kind: 'piece' | 'section' | 'usage', id: string, event: DragEvent) {
@@ -142,6 +172,7 @@
affected?.classList.add('is-updating'); affected?.classList.add('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = true; if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => { return async ({ result, update }) => {
if (result.type === 'success') formElement.closest<HTMLDialogElement>('dialog')?.close();
await update({ reset: result.type === 'success', invalidateAll: true }); await update({ reset: result.type === 'success', invalidateAll: true });
affected?.classList.remove('is-updating'); affected?.classList.remove('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = false; if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
@@ -264,9 +295,9 @@
<p class="eyebrow">Quelle</p> <p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2> <h2 id="source-title">Sendefertiger Fundus</h2>
</div> </div>
<span>{data.pieces.length}</span> <span>{visiblePieces.length}</span>
</header> </header>
<form 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
type="search" type="search"
@@ -286,8 +317,32 @@
> >
<button class="text-button" type="submit">Filtern</button> <button class="text-button" type="submit">Filtern</button>
</form> </form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="builder-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
form="builder-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
<div class="builder-source-list"> <div class="builder-source-list">
{#each data.pieces as piece (piece.id)} {#each visiblePieces as piece (piece.id)}
<article <article
class="builder-source-card" class="builder-source-card"
class:is-dragging={dragged?.kind === 'piece' && dragged.id === piece.id} class:is-dragging={dragged?.kind === 'piece' && dragged.id === piece.id}
@@ -310,6 +365,8 @@
<p> <p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'} {piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
</p> </p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
<PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small> <small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div> </div>
<form <form
@@ -338,10 +395,13 @@
</article> </article>
{/each} {/each}
</div> </div>
{#if !data.pieces.length}<div class="empty-state"> {#if !visiblePieces.length}<div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true">⌕</span>
<h2>Nichts gefunden</h2> <h2>Keine verfügbaren Pieces</h2>
<p>Filter ändern oder Pieces im Fundus sendefertig markieren.</p> <p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
markieren.
</p>
</div>{/if} </div>{/if}
</section> </section>
@@ -507,105 +567,15 @@
aria-label={`${usage.title} nach unten`}>↓</button aria-label={`${usage.title} nach unten`}>↓</button
> >
</form> </form>
<UsageEditorDialog
{usage}
revision={data.show.revision}
sectionId={section.id}
sections={data.show.sections}
error={form?.error}
{enhanceCommand}
/>
</div> </div>
<details class="usage-editor">
<summary>Bearbeiten und verschieben</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<label
>Titel<input
name="title"
required
maxlength="200"
value={usage.title}
/></label
><label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="2"
>{usage.ownNotes}</textarea
></label
><label class="wide-field"
>Einleitung<textarea name="intro" rows="2">{usage.intro}</textarea
></label
><label class="wide-field"
>Recherche<textarea name="research" rows="2"
>{usage.research}</textarea
></label
><label class="wide-field"
>Moderation<textarea name="moderation" rows="2"
>{usage.moderation}</textarea
></label
><label class="wide-field"
>Überleitung<textarea name="transition" rows="2"
>{usage.transition}</textarea
></label
><label class="wide-field"
>Präsentation<textarea name="presentation" rows="2"
>{usage.presentation}</textarea
></label
>
<input type="hidden" name="status" value={usage.status} /><button
class="primary-button wide-field"
type="submit">Verwendung speichern</button
>
</form>
<div class="usage-command-row">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="position"
value="9999"
/><label
>Ziel<select name="sectionId"
><option value="">Reservebank</option
>{#each data.show.sections as target (target.id)}<option
value={target.id}
selected={target.id === section.id}>{target.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') &&
event.preventDefault()}
>
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><button
class="text-button danger-text"
type="submit">Entfernen</button
>
</form>
</div>
</details>
</div> </div>
</div> </div>
{/each} {/each}
@@ -811,6 +781,14 @@
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button
> >
</form> </form>
<UsageEditorDialog
{usage}
revision={data.show.revision}
sectionId=""
sections={data.show.sections}
error={form?.error}
{enhanceCommand}
/>
</div> </div>
</div> </div>
{/each} {/each}
+556 -12
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -29,6 +30,14 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme); await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
} }
async function openEditorialField(page: Page, heading: string) {
const field = page.locator('.editorial-field').filter({
has: page.getByRole('heading', { name: heading, exact: true })
});
await field.locator('.editorial-edit-button').click();
return field;
}
async function login(page: Page, email = adminEmail, password = adminPassword) { async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('E-Mail').fill(email); await page.getByLabel('E-Mail').fill(email);
@@ -61,6 +70,124 @@ function latestMailLink(subject: string): string {
return match[0]; return match[0];
} }
function addSentHistoryFixture(pieceId: string) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)`
);
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, 'Ablauf', NULL, 0, NULL, 0)`
);
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, 0, 'done', 300, 'Historische Verwendung', '', '', '', '', '', '', 0)`
);
database.transaction(() => {
for (const [suffix, title, date] of [
['older', 'Rückblick im September', '2026-09-10T18:00:00.000Z'],
['latest', 'Rückblick im Oktober', '2026-10-10T18:00:00.000Z']
] as const) {
const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date);
insertSection.run(sectionId, showId);
insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId);
}
})();
database.close();
}
function removeSentHistoryFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close();
}
function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const now = '2026-10-22T19:00:00.000Z';
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-presentation', 'E2E Präsentation', ?, '', '', 'ready', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank)
VALUES ('e2e-presentation-section', 'e2e-presentation', 'Player', NULL, 0, NULL, 0),
('e2e-presentation-reserve', 'e2e-presentation', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insert = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot)
VALUES (?, 'e2e-presentation-section', NULL, ?, 'open', 300, ?, '', ?, ?, '', '', '', 0,
'video', ?, ?, ?, ?, '', '', ?, ?, ?)`
);
insert.run(
'e2e-youtube',
0,
'YouTube Kurzlink',
'PRIVATE_RESEARCH_YOUTUBE',
'PRIVATE_MODERATION_YOUTUBE',
'YouTube',
'https://youtu.be/dQw4w9WgXcQ',
75,
'YouTube Vorschau',
'YouTube',
'youtube',
'dQw4w9WgXcQ'
);
insert.run(
'e2e-tiktok',
1,
'TikTok Hochformat',
'PRIVATE_RESEARCH_TIKTOK',
'PRIVATE_MODERATION_TIKTOK',
'TikTok',
'https://www.tiktok.com/@scout2015/video/6718335390845095173',
12,
'TikTok Vorschau',
'TikTok',
'tiktok',
'6718335390845095173'
);
})();
database.close();
}
function removePresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM shows WHERE id = 'e2e-presentation'").run();
database.close();
}
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('/'); await page.goto('/');
@@ -176,6 +303,8 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeHidden();
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 page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(4);
const firstRow = await page.locator('.piece-card').nth(0).boundingBox(); const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
@@ -193,8 +322,8 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await page.getByLabel('Status').selectOption('preparing'); await page.getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Anwenden' }).click(); await page.getByRole('button', { name: 'Anwenden' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page.getByRole('link', { name: 'Filter zurücksetzen' }).click(); await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(1);
await page.getByRole('button', { name: 'Darstellung: System' }).click(); await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
@@ -205,6 +334,118 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
}); });
test('sent and planned source filters stay independent and removing the last usage offers the piece again', async ({
page
}, testInfo) => {
addSentHistoryFixture('piece-effects');
try {
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
const usedPiece = page
.locator('.piece-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' });
await expect(usedPiece).toBeVisible();
await expect(usedPiece).toContainText('Gesendet am 10.10.2026');
await expect(usedPiece).toContainText('+ 1 weitere');
await expect(usedPiece.getByRole('link', { name: 'Rückblick im Oktober' })).toHaveAttribute(
'href',
'/sendungen/e2e-history-latest'
);
await usedPiece.getByText('Vollständige Verwendungshistorie').click();
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte');
await page.getByRole('button', { name: 'Anwenden' }).click();
await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked();
await expect(usedPiece).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'),
fullPage: true
});
await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Filterprüfung');
await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const consolePiece = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await consolePiece.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(consolePiece).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await page.getByPlaceholder('Fundus durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck();
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toHaveCount(0);
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Rückblick im Oktober');
await page.screenshot({
path: testInfo.outputPath('sendungsfilter-desktop.png'),
fullPage: true
});
await page
.locator('.reserve-usage')
.getByRole('button', { name: /aus Reserve entfernen/ })
.click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungsfilter-mobile.png'),
fullPage: true
});
} finally {
removeSentHistoryFixture();
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
@@ -322,10 +563,12 @@ test('a show can be planned from a template with independent usages and reserve'
await expect(source).toHaveClass(/is-dragging/); await expect(source).toHaveClass(/is-dragging/);
await expect(source).toHaveCSS('opacity', '0.45'); await expect(source).toHaveCSS('opacity', '0.45');
await emptyReactionTarget.dispatchEvent('drop', { dataTransfer }); await emptyReactionTarget.dispatchEvent('drop', { dataTransfer });
await sourceDragHandle.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose(); await dataTransfer.dispose();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(1); await expect(reactionSection.locator('.editable-usage')).toHaveCount(1);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable'); await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await expect(source).toHaveCount(0);
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(source).toContainText('Eingeplant');
await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage')); await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage'));
let usages = page let usages = page
@@ -333,7 +576,41 @@ test('a show can be planned from a template with independent usages and reserve'
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage'); .locator('.editable-usage');
await expect(usages).toHaveCount(2); await expect(usages).toHaveCount(2);
await usages.nth(0).locator('summary').click(); await usages
.nth(0)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie');
const desktopEditorItems = await page
.locator('dialog[open] .usage-dialog-editor')
.evaluate((element) =>
Array.from(element.children)
.filter((child) => child.matches('section, button'))
.map((child) => child.getBoundingClientRect().top)
);
expect(Math.abs(desktopEditorItems[0] - desktopEditorItems[1])).toBeLessThan(2);
expect(desktopEditorItems[2]).toBeGreaterThan(desktopEditorItems[1]);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-desktop.png'),
fullPage: true
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Aus Fundus aktualisieren' })
.click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(0)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt'); await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt');
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).getByLabel('Eigene Notizen').fill('Kurbel zuerst zeigen');
@@ -344,20 +621,51 @@ test('a show can be planned from a template with independent usages and reserve'
.locator('.run-section') .locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage'); .locator('.editable-usage');
await usages.nth(1).locator('summary').click(); await usages
.nth(1)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick'); await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick');
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).getByLabel('Eigene Notizen').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.');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(1)
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await usages.nth(1).getByRole('button', { name: 'Duplizieren' }).click();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(3);
await expect(reactionSection.locator('.editable-usage').nth(2)).toContainText(
'Konsole als Rückblick'
);
await expect(page.locator('.show-summary')).toContainText('2 Std. 40 Min.');
const planTimeBeforeReserve = await page.locator('.show-summary').textContent(); const planTimeBeforeReserve = await page.locator('.show-summary').textContent();
await source.getByLabel(/Ziel für/).selectOption(''); await source.getByLabel(/Ziel für/).selectOption('');
await source.getByRole('button', { name: 'Hinzufügen' }).click(); await source.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(page.locator('.reserve-usage')).toHaveCount(1); await expect(page.locator('.reserve-usage')).toHaveCount(1);
expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve); expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve);
await page
.locator('.reserve-usage')
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsspezifische Kopie');
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible();
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }); const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }).first();
await moveUp.focus(); await moveUp.focus();
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await expect( await expect(
@@ -384,12 +692,60 @@ test('a show can be planned from a template with independent usages and reserve'
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');
await page.reload(); await page.reload();
await expect(page.locator('.editable-usage')).toHaveCount(3); await expect(page.locator('.editable-usage')).toHaveCount(4);
const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' }); const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' });
await savedUsage.locator('summary').click(); await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen'); await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const duplicatedUsage = page
.locator('.editable-usage')
.filter({ hasText: 'Konsole als Rückblick' });
await expect(duplicatedUsage).toHaveCount(2);
await duplicatedUsage
.nth(1)
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15');
await expect(duplicatedUsage.nth(1).getByLabel('Eigene Notizen')).toHaveValue(
'Andere Perspektive'
);
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
const mobileEditorItems = await page
.locator('dialog[open] .usage-dialog-editor')
.evaluate((element) =>
Array.from(element.children)
.filter((child) => child.matches('section, button'))
.map((child) => child.getBoundingClientRect().top)
);
expect(mobileEditorItems[1]).toBeGreaterThan(mobileEditorItems[0]);
expect(mobileEditorItems[2]).toBeGreaterThan(mobileEditorItems[1]);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-mobile.png'),
fullPage: true
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Entfernen' })
.scrollIntoViewIfNeeded();
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Entfernen' })
).toBeVisible();
await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
.evaluate((element) => ((element as HTMLInputElement).value = '1'));
await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('dialog[open]').getByRole('alert')).toContainText(
'inzwischen geändert'
);
await 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');
await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click(); await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click();
@@ -397,7 +753,7 @@ test('a show can be planned from a template with independent usages and reserve'
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true });
await page.goto('/fundus?q=kleine+Konsole'); await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await expect( await expect(
page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible(); ).toBeVisible();
@@ -430,7 +786,7 @@ test('all work areas fit at required widths in light and dark mode', async ({ pa
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => { test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(1);
await page.evaluate(() => { await page.evaluate(() => {
const list = document.querySelector('.piece-grid'); const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card')); const rows = Array.from(document.querySelectorAll('.piece-card'));
@@ -444,7 +800,7 @@ test('long titles and longer fundus lists reflow without overflow', async ({ pag
} }
}); });
await expect(page.locator('.piece-card')).toHaveCount(16); await expect(page.locator('.piece-card')).toHaveCount(4);
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
@@ -486,12 +842,71 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
expect(boxes[1]?.y).toBe(boxes[2]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y);
}); });
test('validated YouTube and TikTok players switch without exposing private fields', async ({
page
}, testInfo) => {
addPresentationFixture();
try {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>YouTube Test</title>'
})
);
await page.context().route('https://www.tiktok.com/player/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>TikTok Test</title>'
})
);
await page.goto('/live?show=e2e-presentation');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click();
const output = await popupPromise;
await output.waitForLoadState('networkidle');
await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible();
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
const youtube = output.locator('iframe[title="YouTube Vorschau"]');
await expect(youtube).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
);
await expect(output.locator('.output-connection')).toHaveClass(/sr-only/);
const publicState = await output.evaluate(async () =>
JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json()))
);
expect(publicState).not.toContain('PRIVATE_RESEARCH');
expect(publicState).not.toContain('PRIVATE_MODERATION');
await page.getByRole('button', { name: 'TikTok Hochformat präsentieren' }).click();
await expect(youtube).toHaveCount(0);
await expect(output.locator('iframe[title="TikTok Vorschau"]')).toHaveAttribute(
'src',
'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1'
);
await expect(output.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute(
'href',
'https://www.tiktok.com/@scout2015/video/6718335390845095173'
);
await output.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(output);
await output.screenshot({
path: testInfo.outputPath('heinzlive-video-mobile.png'),
fullPage: true
});
await output.close();
} finally {
removePresentationFixture();
}
});
test('live control persists state and exposes only explicit presentation data', async ({ test('live control persists state and exposes only explicit presentation data', async ({
page, page,
browser browser
}, testInfo) => { }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole'); 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();
const png = Buffer.from( const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@@ -607,13 +1022,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl); await expect(page).toHaveURL(pieceUrl);
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await page await page
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.'); .fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte');
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await openEditorialField(page, 'Präsentationstext');
await page await page
.getByLabel('Präsentationstext') .getByLabel('Präsentationstext')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); .fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
@@ -625,11 +1047,18 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200); await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await page
.locator('.editorial-field')
.filter({ has: page.getByRole('heading', { name: 'Präsentationstext', exact: true }) })
.getByRole('button', { name: 'Lesen' })
.click();
await expect(page.locator('.markdown-preview script')).toHaveCount(0); await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau'); await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload(); await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue( await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );
@@ -660,6 +1089,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await concurrent.goto(pieceUrl); await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert'); await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await concurrent await concurrent
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.'); .fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -668,6 +1098,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
}); });
await concurrent.reload(); await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).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.'
); );
@@ -692,12 +1123,125 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'Kopie von E2E Fundstück, extern aktualisiert' 'Kopie von E2E Fundstück, extern aktualisiert'
); );
await page.context().setOffline(true);
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000
});
await page.context().setOffline(false);
await page.getByRole('button', { name: 'Jetzt speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark'); await setTheme(page, 'dark');
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Quellen', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Quellen schließen' }).click();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'), path: testInfo.outputPath('piece-editor-mobile-dark.png'),
fullPage: true fullPage: true
}); });
}); });
test('a quickly captured video can be written and previewed before classification', async ({
page
}) => {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>YouTube Test</title>'
})
);
await page.goto('/eingang');
await page.getByLabel('Titel oder Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Aufnehmen und öffnen' }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
await expect(page.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute(
'href',
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await page
.getByLabel('Private Recherche')
.fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByLabel('Moderationsstichpunkte')
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByLabel('Video').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Präsentation testen' }).click();
const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
);
await preview.close();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Video')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation schließen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
page
}, testInfo) => {
await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByLabel('Linkvorschau').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau'
);
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Präsentation testen' }).click();
const preview = await previewPromise;
await preview.waitForLoadState('networkidle');
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
await expect(
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(0);
await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true
});
await preview.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(preview);
await preview.screenshot({
path: testInfo.outputPath('linkvorschau-mobile.png'),
fullPage: true
});
await preview.close();
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview')
);
await page.getByRole('button', { name: 'Vorschau laden' }).click();
expect((await blockedPreview).status()).toBe(400);
await expect(page.getByRole('alert')).toContainText('Interne oder private Netzwerkziele');
});