From 5a9d7735d41ffca847d45c5544b4db1063ce3468 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Thu, 22 Oct 2026 00:28:49 +0200 Subject: [PATCH] Release 0.5.0 --- CloudronManifest.json | 2 +- README.md | 27 +- docs/ACCEPTANCE.md | 28 + docs/ARCHITECTURE.md | 40 +- docs/DECISIONS.md | 23 + docs/STATUS.md | 34 +- migrations/0005_live_mode.sql | 45 ++ package-lock.json | 4 +- package.json | 2 +- scripts/seed.mjs | 2 +- src/app.css | 267 +++++++++ src/app.d.ts | 1 + src/hooks.server.ts | 7 +- src/lib/server/database.spec.ts | 2 +- src/lib/server/live.spec.ts | 137 +++++ src/lib/server/live.ts | 563 ++++++++++++++++++ src/routes/+layout.server.ts | 5 +- src/routes/+layout.svelte | 4 +- src/routes/heinzlive/[token]/+page.server.ts | 9 + src/routes/heinzlive/[token]/+page.svelte | 74 +++ .../heinzlive/[token]/media/[id]/+server.ts | 18 + src/routes/heinzlive/[token]/state/+server.ts | 9 + src/routes/live/+page.server.ts | 138 ++++- src/routes/live/+page.svelte | 425 +++++++++++-- src/routes/sendungen/[id]/+page.svelte | 2 +- tests/ui.e2e.ts | 100 +++- 26 files changed, 1855 insertions(+), 113 deletions(-) create mode 100644 migrations/0005_live_mode.sql create mode 100644 src/lib/server/live.spec.ts create mode 100644 src/lib/server/live.ts create mode 100644 src/routes/heinzlive/[token]/+page.server.ts create mode 100644 src/routes/heinzlive/[token]/+page.svelte create mode 100644 src/routes/heinzlive/[token]/media/[id]/+server.ts create mode 100644 src/routes/heinzlive/[token]/state/+server.ts diff --git a/CloudronManifest.json b/CloudronManifest.json index d0d15d9..a17e851 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.4.0", + "version": "0.5.0", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/README.md b/README.md index 49e25f4..27764db 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst -Etappe 1 bis 4: technisches Grundgerüst, Anmeldung und Rollen, einen -persistenten Fundus sowie den vollständigen Sendungsbaukasten. Live-Steuerung -und Präsentationsausgabe folgen in späteren Etappen. +Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen +persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine +abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6. ## Voraussetzungen @@ -98,6 +98,27 @@ Schaltflächen und Auswahlfeldern verschieben. Twitch-Titel und Ankündigung sin kopierbar. Gleichzeitige Änderungen werden über die Revision der gesamten Sendung erkannt und nicht still überschrieben. +## Live moderieren und präsentieren + +Die Live-Steuerung öffnet sich aus einer geplanten Sendung. Sie zeigt +Einleitung, Moderationsstichpunkte und Überleitung in einstellbarer +Prompter-Schrift; die private Recherche bleibt standardmäßig geschlossen. +Zeiten und Ablaufleiste können ausgeblendet werden. Schriftgröße, Sichtbarkeit +und Leseposition bleiben im Browser erhalten. + +Start, Erledigen, Überspringen und Zurück werden persistent mit tatsächlichen +Zeitintervallen gespeichert. Wiederholte Befehle erzeugen keine doppelte +Zeitbuchung. Offene Pieces und die Reserve lassen sich während der Sendung +sortieren beziehungsweise als nächster Beitrag einfügen. + +Das separate Präsentationsfenster unter `/heinzlive/...` verwendet einen langen, +widerrufbaren Zufallstoken. Es ändert sich ausschließlich durch die Aktion +**Präsentieren** und erhält nur Präsentationstext, öffentliche Quellen und die +zum präsentierten Piece gehörenden Bilder. Recherche, Moderationsnotizen, +eigene Notizen, Kontodaten und interne Dateipfade werden weder im HTML noch im +JSON-Zustand ausgeliefert. Der Token ist ein Zugangsgeheimnis und darf nicht +weitergegeben oder protokolliert werden. + ## Befehle | Befehl | Zweck | diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 5f7f403..6e7fef9 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -120,3 +120,31 @@ ungewollten horizontalen Überlauf - Mobile Aufnahme des vollständigen Sendungsbaukastens visuell kontrolliert - `bash -n start.sh` und `git diff --check`: erfolgreich + +## Etappe 5 + +| Kriterium | Nachweis | Stand | +| ------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------- | +| Lesbare Moderation | Prompter-Schrift, Einleitung und Überleitung im Browser; Recherche initial geschlossen | erfüllt am 22.10.2026 | +| Wiederherstellung | Live-Stand, Laufintervalle, Schriftgröße und Leseposition nach Neuladen geprüft | erfüllt am 22.10.2026 | +| Keine doppelte Zeiterfassung | Verhaltenstest wiederholt dieselbe Befehls-ID und findet genau ein Zeitintervall | erfüllt am 22.10.2026 | +| Explizite Präsentation | Zweitfenster bleibt nach Start leer und ändert sich erst durch „Präsentieren“ | erfüllt am 22.10.2026 | +| Getrennte sichere Ausgabe | anonymer Browserkontext prüft Text, Bild, HTML, JSON und fremde Medien ohne private Daten | erfüllt am 22.10.2026 | +| Reserve und Reihenfolge | Reserve als nächstes Piece eingefügt; offene Reihenfolge und serverseitige Live-Grenze geprüft | erfüllt am 22.10.2026 | +| Popup- und Verbindungsfehler sichtbar | blockiertes Popup sowie unterbrochenes und wiederhergestelltes State-Polling im Browser geprüft | erfüllt am 22.10.2026 | +| Reale OBS-Browserquelle | lokale tokenisierte Browserausgabe vorhanden; kein OBS auf der Prüfmaschine | offen | + +## Ausgeführte Prüfungen Etappe 5 + +- `npm run lint`: erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- `npm test`: 20 Tests in 5 Dateien erfolgreich +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` +- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich +- Anonymer zweiter Chromium-Kontext mit Präsentationstext, Quelle, geschütztem + Bild und gefiltertem JSON geprüft +- Mobile Aufnahme der Live-Steuerung bei 390 × 844 Pixeln und Desktopaufnahme + der heinzlive-Ausgabe visuell kontrolliert +- Popup-Blockade sowie Verbindungsabbruch und automatische Wiederverbindung im + Browser geprüft +- `bash -n start.sh` und `git diff --check`: erfolgreich diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21282ff..bc585b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,8 +33,9 @@ spätere API dieselben Regeln verwenden. ## Gesamtdatenmodell -Die ersten vier Etappen materialisieren Identität, Inhaltsverwaltung und -Sendungsplanung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche. +Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung, +Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte +und spätere Bereiche. | Bereich | Geplante Entitäten und Beziehungen | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | @@ -149,6 +150,32 @@ Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst beim Loslassen. +## Live-Modus und Präsentationsgrenze + +`src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der +Live-Stand besitzt eine eigene Revision. Jeder steuernde Befehl trägt zusätzlich +eine vom Browser erzeugte Befehls-ID; `(show_id, command_id)` ist eindeutig und +liefert bei Wiederholung das bereits gespeicherte Ergebnis. Tatsächliche Zeiten +liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben +Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der +geplanten Dauer nachvollziehbar. + +Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf +Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe +`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation +oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein +neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und +Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden +nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören. +Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den +Präsentationsstand nicht. + +Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per +`no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler +sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung +mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses +Zustandsobjekts. + ## Oberflächenstruktur Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. @@ -158,12 +185,9 @@ die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. -Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere -Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht. - -Fundus und Sendungsbau sind produktive Arbeitsansichten. Der Live-Modus bleibt -bis Etappe 5 eine sichtbare Gestaltungsvorschau, kann aber bereits gezielt die -gewählte Sendung und deren kopierte Texte lesen. +Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die +Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht +ohne interne Anwendungsnavigation. ## Sicherheitsgrenzen ab den Folgeetappen diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 182fced..938d71c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -202,3 +202,26 @@ Quellen: - - + +## D-016: Live-Befehle idempotent, tatsächliche Zeiten als Intervalle + +Die Live-Steuerung verwendet eine eigene Revision und zusätzlich eine pro +Bedienaktion erzeugte Befehls-ID. Die Datenbank akzeptiert jede Kombination aus +Sendung und Befehls-ID nur einmal. Ein nach Verbindungsabbruch wiederholter +Befehl liefert dieselbe Ergebnisrevision und erzeugt weder einen zweiten +Statuswechsel noch ein zweites Zeitintervall. Laufzeit wird nicht als einzelner +überschreibbarer Wert, sondern als Summe abgeschlossener und höchstens eines +aktiven Intervalls ermittelt. Dadurch bleiben Zurückgehen und Wiederaufnahme +korrekt. + +## D-017: Präsentationsausgabe als Positivliste mit gehashtem Zugang + +Die heinzlive-Ausgabe ist kein eingeschränkter Aufruf der internen Live-Ansicht, +sondern eine eigene Datenprojektion. Zulässig sind ausschließlich ausdrücklich +präsentierter Titel und Präsentationstext, öffentliche Quellen sowie zugehörige +Bilder und Alternativtexte. Recherche, Einleitung, Moderation, eigene Notizen, +Ablauf, Nutzer und interne Dateinamen werden nicht in HTML oder JSON übernommen. +Ein zufälliger 256-Bit-Token steht in der URL, während SQLite nur seinen +SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und +Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`- +Polling statt über eine dauerhaft offene Serververbindung. diff --git a/docs/STATUS.md b/docs/STATUS.md index 69f479c..7290e1d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,10 @@ ## Aktueller Umfang -Etappe 1 bis Etappe 4 sind abgeschlossen. Architektur, Gesamtdatenmodell, -Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung und lokaler -Betriebsweg sind dokumentiert. Etappe 5 wurde nicht begonnen. +Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell, +Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, +Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. +Etappe 6 wurde nicht begonnen. ## Umgesetzter Stand Etappe 1 @@ -37,9 +38,8 @@ umgebrochen. ## Noch nicht umgesetzt -Echte Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und -API. Die verbleibenden Vorschauen dürfen nicht als -Implementierung dieser späteren Funktionen verstanden werden. +Archivabschluss, Freigaben, RSS und API. Diese späteren Bereiche dürfen nicht +als bereits implementiert verstanden werden. ## Umgesetzter Stand Etappe 2 @@ -90,6 +90,23 @@ Implementierung dieser späteren Funktionen verstanden werden. vor unbemerktem parallelem Überschreiben - realistischer Seed mit Standardablauf und eigenständigem Spieleabend +## Umgesetzter Stand Etappe 5 + +- fokussierte Moderationsansicht mit Prompter-Schrift, Einleitung, Moderation, + Überleitung und standardmäßig geschlossener privater Recherche +- ausblendbare Zeit- und Ablaufanzeige sowie persistente lokale Leseposition +- aktuelles und nächstes Piece samt Abschnitt und geplantem Zeitbedarf +- persistenter Live-Stand mit tatsächlichen Laufintervallen, Erledigen, + Überspringen, Zurück und Wiederaufnahme +- idempotente Live-Befehle verhindern doppelte Wirkung bei Wiederholung +- serverseitig geschützte Umsortierung offener Pieces und Reserveeinfügung +- explizite Präsentationsaktion; normale Live-Navigation verändert die Ausgabe + nicht +- getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige, + Präsentationstext, Quellen und erlaubten Bildern +- Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche, + Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade + ## Bekannte offene Betriebsprüfung Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten @@ -98,6 +115,11 @@ durchgeführt werden. Auch der lokale Container-Smoke-Test blieb offen, weil auf der Prüfmaschine kein Docker-Daemon lief. Manifestkernfelder und Startskript-Syntax wurden lokal geprüft. +Eine reale OBS-Browserquelle stand nicht zur Verfügung. Die tokenisierte +Präsentationsausgabe wurde stattdessen in einem anonymen zweiten Chromium- +Browserkontext einschließlich Bild, JSON-Filterung, Polling und sichtbarem +Verbindungsabbruch geprüft. + ## Verifizierte Umgebung - Node.js 22.23.1, npm 10.9.8 diff --git a/migrations/0005_live_mode.sql b/migrations/0005_live_mode.sql new file mode 100644 index 0000000..41ace40 --- /dev/null +++ b/migrations/0005_live_mode.sql @@ -0,0 +1,45 @@ +CREATE TABLE show_live_states ( + show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE, + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + current_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL, + started_at_utc TEXT, + ended_at_utc TEXT, + presentation_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL, + presentation_revision INTEGER NOT NULL DEFAULT 0 CHECK (presentation_revision >= 0), + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE live_usage_runs ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE, + started_at_utc TEXT NOT NULL, + ended_at_utc TEXT, + CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc) +); + +CREATE UNIQUE INDEX idx_one_active_live_run_per_show + ON live_usage_runs(show_id) WHERE ended_at_utc IS NULL; +CREATE INDEX idx_live_runs_usage ON live_usage_runs(usage_id, started_at_utc); + +CREATE TABLE live_commands ( + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + command_id TEXT NOT NULL, + action TEXT NOT NULL, + result_revision INTEGER NOT NULL CHECK (result_revision >= 0), + created_at_utc TEXT NOT NULL, + PRIMARY KEY (show_id, command_id) +); + +CREATE TABLE presentation_access_tokens ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at_utc TEXT NOT NULL, + revoked_at_utc TEXT +); + +CREATE INDEX idx_presentation_tokens_show + ON presentation_access_tokens(show_id, revoked_at_utc); + +UPDATE piece_usages SET status = 'open' WHERE status = 'current'; diff --git a/package-lock.json b/package-lock.json index 26b9510..6f2bb92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index af35ead..fdd6a0c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 53c1e0c..a5252dc 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -348,7 +348,7 @@ export function seed(databasePath = getPaths().database) { 'section-stories', 'piece-console', 0, - 'current', + 'open', 480, 'Die kleine Konsole mit dem großen Dreh', 'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?', diff --git a/src/app.css b/src/app.css index 4b6cc53..d9b3247 100644 --- a/src/app.css +++ b/src/app.css @@ -1101,6 +1101,273 @@ a { font-size: 0.61rem; } +.live-state-line { + margin: 0.4rem 0 0; + color: var(--text-muted); + font-size: 0.75rem; +} + +.live-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.55rem; + margin-bottom: 1rem; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--panel); +} + +.live-session-summary { + display: grid; + margin-left: auto; + text-align: right; +} + +.live-session-summary strong { + font-size: 0.78rem; +} + +.live-session-summary span { + color: var(--text-muted); + font-size: 0.65rem; +} + +.live-grid.without-rail { + grid-template-columns: minmax(0, 1fr); +} + +.live-markdown > :first-child, +.prompter-copy > :first-child { + margin-top: 0; +} + +.live-markdown > :last-child, +.prompter-copy > :last-child { + margin-bottom: 0; +} + +.live-current-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.4rem; + padding-top: 1rem; + border-top: 1px solid var(--line); +} + +.live-current-actions form:last-child { + margin-left: auto; +} + +.live-run-item, +.live-reserve-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + margin-top: 0.35rem; + padding: 0.5rem 0.55rem; + border-radius: 0.65rem; + background: var(--panel-soft); +} + +.live-run-item.current { + box-shadow: inset 3px 0 var(--accent); + background: var(--accent-soft); +} + +.live-run-item > div:first-child, +.live-reserve-item > div:first-child { + display: grid; + min-width: 0; +} + +.live-run-item strong, +.live-reserve-item strong { + font-size: 0.7rem; + overflow-wrap: anywhere; +} + +.live-run-item small, +.live-reserve-item small { + color: var(--text-muted); + font-size: 0.61rem; +} + +.live-run-actions { + display: flex; + align-items: center; + gap: 0.2rem; +} + +.live-run-actions form { + display: flex; +} + +.live-run-actions button:not(.text-button) { + min-width: 1.65rem; + min-height: 1.65rem; + padding: 0; +} + +.live-run-actions .text-button { + padding: 0.3rem; + font-size: 0.62rem; +} + +.live-reserve { + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line)); + border-radius: 1rem; + background: color-mix(in srgb, var(--gold) 5%, var(--panel)); +} + +.live-reserve > header { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + font-weight: 720; +} + +.live-reserve > p { + margin: 0.65rem 0 0; + color: var(--text-muted); + font-size: 0.68rem; +} + +.heinzlive-output { + min-height: 100vh; + padding: clamp(2rem, 6vw, 6rem); + background: + radial-gradient(circle at 12% 15%, rgb(103 221 232 / 12%), transparent 32rem), #0b1220; + color: #fff7e8; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +.output-connection { + position: fixed; + top: 1rem; + right: 1rem; + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.65rem; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 2rem; + background: rgb(11 18 32 / 78%); + color: rgb(255 247 232 / 68%); + font-size: 0.68rem; + backdrop-filter: blur(10px); +} + +.output-connection span { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: #67dde8; +} + +.output-connection.problem { + color: #ffd0cb; +} + +.output-connection.problem span { + background: #ff776d; +} + +.output-content { + display: grid; + max-width: 88rem; + min-height: calc(100vh - clamp(4rem, 12vw, 12rem)); + align-content: center; + margin: 0 auto; +} + +.output-kicker, +.output-sources > span { + color: #67dde8; + font-size: clamp(0.68rem, 1.2vw, 0.9rem); + font-weight: 760; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.output-content h1, +.output-idle h1 { + max-width: 16ch; + margin: 0.45rem 0 1.5rem; + font-size: clamp(2.8rem, 8vw, 8rem); + letter-spacing: -0.055em; + line-height: 0.94; +} + +.output-copy { + max-width: 44ch; + font-family: ui-serif, Georgia, serif; + font-size: clamp(1.4rem, 2.8vw, 2.7rem); + line-height: 1.35; +} + +.output-copy a, +.output-sources a { + color: #e7b957; +} + +.output-images { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.output-images img { + width: 100%; + max-height: 48vh; + object-fit: contain; + border-radius: 1.2rem; + background: rgb(255 255 255 / 5%); +} + +.output-sources { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.65rem 1rem; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid rgb(255 255 255 / 14%); +} + +.output-sources a { + font-size: 0.85rem; +} + +.output-idle { + display: grid; + min-height: calc(100vh - clamp(4rem, 12vw, 12rem)); + place-content: center; + text-align: center; +} + +.output-idle .brand-mark { + margin: 0 auto 1.5rem; + transform: scale(1.5); +} + +.output-idle h1 { + max-width: 18ch; + font-size: clamp(2.4rem, 6vw, 5.5rem); +} + +.output-idle p { + margin: 0; + color: rgb(255 247 232 / 62%); + font-size: clamp(0.9rem, 1.8vw, 1.3rem); +} + .quiet-panel { border: 1px solid var(--line); border-radius: var(--radius-large); diff --git a/src/app.d.ts b/src/app.d.ts index 3297b73..f230758 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -7,6 +7,7 @@ declare global { } interface PageData { user?: AuthUser | null; + presentationMode?: boolean; } } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index df33f4a..7a2048d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -13,11 +13,12 @@ const publicPaths = new Set([ ]); export const handle: Handle = async ({ event, resolve }) => { + const presentationPath = event.url.pathname.startsWith('/heinzlive/'); const token = event.cookies.get(SESSION_COOKIE); event.locals.user = getSessionUser(token); if (token && !event.locals.user) deleteSession(event.cookies); - if (!publicPaths.has(event.url.pathname) && !event.locals.user) { + if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) { redirect(303, hasUsers() ? '/login' : '/setup'); } @@ -25,6 +26,10 @@ export const handle: Handle = async ({ event, resolve }) => { response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Frame-Options', 'DENY'); + if (presentationPath) { + response.headers.set('Cache-Control', 'no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + } if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { response.headers.set('Cache-Control', 'no-store'); } diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 28357b9..2b34a0c 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -62,7 +62,7 @@ describe('database setup', () => { database.close(); expect(counts).toEqual({ - migrations: 4, + migrations: 5, pieces: 6, shows: 2, usages: 5, diff --git a/src/lib/server/live.spec.ts b/src/lib/server/live.spec.ts new file mode 100644 index 0000000..4404d68 --- /dev/null +++ b/src/lib/server/live.spec.ts @@ -0,0 +1,137 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { closeDatabase, getDatabase } from './database.js'; +import { + completeCurrent, + getLiveView, + getPresentationMedia, + getPresentationView, + goBack, + issuePresentationAccess, + moveOpenUsage, + presentUsage, + skipCurrent, + startOrResumeLive +} from './live.js'; + +let directory: string; + +beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-live-test-')); + process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite'); + process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads'); + execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env }); + execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env }); +}); + +afterEach(() => { + closeDatabase(); + delete process.env.SENDEWERK_DB_PATH; + delete process.env.SENDEWERK_UPLOAD_DIR; + rmSync(directory, { recursive: true, force: true }); +}); + +describe('live mode', () => { + it('restores live state, records actual intervals and applies commands only once', () => { + const started = new Date('2026-10-22T18:00:00.000Z'); + const initial = getLiveView('show-october', started); + expect(initial.current).toBeNull(); + + expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({ + revision: 1, + duplicate: false + }); + expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({ + revision: 1, + duplicate: true + }); + let live = getLiveView('show-october', new Date('2026-10-22T18:05:00.000Z')); + expect(live.current?.id).toBe('usage-console'); + expect(live.current?.actualDurationSeconds).toBe(300); + expect(() => + moveOpenUsage('show-october', 'usage-effects', live.show.revision, { + sectionId: 'section-stories', + position: 0 + }) + ).toThrow('LIVE_ORDER_LOCKED'); + + completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z')); + completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z')); + live = getLiveView('show-october', new Date('2026-10-22T18:06:00.000Z')); + expect(live.current?.id).toBe('usage-effects'); + expect( + live.sections + .flatMap((section) => section.usages) + .find((usage) => usage.id === 'usage-console') + ).toMatchObject({ status: 'done', actualDurationSeconds: 300 }); + expect( + getDatabase() + .prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?') + .get('usage-console') + ).toEqual({ count: 1 }); + + expect(() => skipCurrent('show-october', 'stale-command', 1)).toThrow('CONFLICT'); + skipCurrent('show-october', 'skip-effects', 2, new Date('2026-10-22T18:06:00.000Z')); + live = getLiveView('show-october', new Date('2026-10-22T18:07:00.000Z')); + expect(live.current).toBeNull(); + expect(live.completedCount).toBe(2); + + goBack('show-october', 'back-effects', 3, new Date('2026-10-22T18:07:00.000Z')); + live = getLiveView('show-october', new Date('2026-10-22T18:08:00.000Z')); + expect(live.current).toMatchObject({ id: 'usage-effects', status: 'current' }); + expect(live.current?.actualDurationSeconds).toBe(120); + }); + + it('changes presentation only explicitly and exposes no private fields', async () => { + const uploads = process.env.SENDEWERK_UPLOAD_DIR!; + mkdirSync(uploads, { recursive: true }); + writeFileSync(path.join(uploads, 'presentation.png'), Buffer.from('test-image')); + getDatabase() + .prepare( + `INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type, + byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + 'presentation-image', + 'piece-console', + 'presentation.png', + 'private-original-name.png', + 'image/png', + 10, + 'Konsole auf dem Tisch', + 0, + '2026-10-22T18:00:00.000Z' + ); + + const token = issuePresentationAccess('show-october'); + expect(getPresentationView(token)).toEqual({ revision: 0, content: null }); + startOrResumeLive('show-october', 'start-output', 0); + expect(getPresentationView(token)).toEqual({ revision: 0, content: null }); + + presentUsage('show-october', 'usage-console', 'present-console', 1); + const output = getPresentationView(token)!; + expect(output.content).toMatchObject({ + title: 'Die kleine Konsole mit dem großen Dreh', + images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }] + }); + const serialized = JSON.stringify(output); + expect(serialized).toContain('Weniger Leistung, mehr Idee.'); + expect(serialized).not.toContain('Herstellerangaben'); + expect(serialized).not.toContain('Moderationsstichpunkte'); + expect(serialized).not.toContain('private-original-name'); + expect(serialized).not.toContain('presentation.png'); + + const media = await getPresentationMedia(token, 'presentation-image'); + expect(media?.data.toString()).toBe('test-image'); + expect(await getPresentationMedia(token, 'unknown-image')).toBeNull(); + + const replacementToken = issuePresentationAccess('show-october'); + expect(getPresentationView(token)).toBeNull(); + expect(getPresentationView(replacementToken)?.content?.title).toBe( + 'Die kleine Konsole mit dem großen Dreh' + ); + }); +}); diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts new file mode 100644 index 0000000..1e59763 --- /dev/null +++ b/src/lib/server/live.ts @@ -0,0 +1,563 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import type Database from 'better-sqlite3'; +import { getMediaFile } from './content.js'; +import { getDatabase } from './database.js'; +import { renderMarkdown } from './markdown.js'; +import { + getShow, + movePieceUsage, + type MovePieceUsageInput, + type PieceUsage, + type ShowSection +} from './shows.js'; + +type LiveStateRow = { + show_id: string; + revision: number; + current_usage_id: string | null; + started_at_utc: string | null; + ended_at_utc: string | null; + presentation_usage_id: string | null; + presentation_revision: number; + updated_at_utc: string; +}; + +type OrderedUsageRow = { + id: string; + section_id: string; + section_name: string; + section_position: number; + position: number; + status: PieceUsage['status']; +}; + +export type LiveUsage = PieceUsage & { + sectionId: string; + sectionName: string; + actualDurationSeconds: number; + introHtml: string; + researchHtml: string; + moderationHtml: string; + transitionHtml: string; +}; + +export type LiveSection = Omit & { usages: LiveUsage[] }; + +export type LiveView = { + show: { + id: string; + title: string; + status: string; + revision: number; + totalDurationSeconds: number; + }; + liveRevision: number; + startedAtUtc: string | null; + updatedAtUtc: string; + presentationUsageId: string | null; + presentationRevision: number; + sections: LiveSection[]; + reserve: LiveUsage[]; + current: LiveUsage | null; + next: LiveUsage | null; + completedCount: number; + totalCount: number; +}; + +export type PresentationView = { + revision: number; + content: null | { + title: string; + html: string; + sources: { label: string; url: string; timecodeSeconds: number | null }[]; + images: { id: string; altText: string }[]; + }; +}; + +function hashToken(token: string) { + return createHash('sha256').update(token).digest('hex'); +} + +function nowIso(now?: Date) { + return (now ?? new Date()).toISOString(); +} + +function requireCommandId(commandId: string) { + if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND'); +} + +function ensureLiveState(database: Database.Database, showId: string, now: string): LiveStateRow { + const show = database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId); + if (!show) throw new Error('NOT_FOUND'); + database + .prepare( + `INSERT OR IGNORE INTO show_live_states (show_id, updated_at_utc) + VALUES (?, ?)` + ) + .run(showId, now); + return database + .prepare('SELECT * FROM show_live_states WHERE show_id = ?') + .get(showId) as LiveStateRow; +} + +function orderedUsages(database: Database.Database, showId: string): OrderedUsageRow[] { + return database + .prepare( + `SELECT u.id, s.id AS section_id, s.name AS section_name, + s.position AS section_position, u.position, u.status + FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE s.show_id = ? AND s.is_reserve_bank = 0 + ORDER BY s.position, u.position` + ) + .all(showId) as OrderedUsageRow[]; +} + +function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) { + const row = database + .prepare( + `SELECT s.is_reserve_bank FROM piece_usages u + JOIN show_sections s ON s.id = u.show_section_id + WHERE u.id = ? AND s.show_id = ?` + ) + .get(usageId, showId) as { is_reserve_bank: number } | undefined; + if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND'); +} + +function closeActiveRun(database: Database.Database, showId: string, now: string) { + database + .prepare( + `UPDATE live_usage_runs SET ended_at_utc = ? + WHERE show_id = ? AND ended_at_utc IS NULL` + ) + .run(now, showId); +} + +function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) { + usageBelongsToShow(database, showId, usageId); + database + .prepare( + `UPDATE piece_usages SET status = 'open' + WHERE status = 'current' AND id != ? AND id IN ( + SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE s.show_id = ? + )` + ) + .run(usageId, showId); + database.prepare(`UPDATE piece_usages SET status = 'current' WHERE id = ?`).run(usageId); + database + .prepare( + `INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc) + VALUES (?, ?, ?, ?)` + ) + .run(randomUUID(), showId, usageId, now); + database + .prepare( + 'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?' + ) + .run(usageId, showId); +} + +function executeCommand( + showId: string, + commandId: string, + expectedRevision: number, + action: string, + operation: (database: Database.Database, state: LiveStateRow, now: string) => void, + nowValue?: Date +) { + requireCommandId(commandId); + const database = getDatabase(); + return database.transaction(() => { + const duplicate = database + .prepare('SELECT result_revision FROM live_commands WHERE show_id = ? AND command_id = ?') + .get(showId, commandId) as { result_revision: number } | undefined; + if (duplicate) return { revision: duplicate.result_revision, duplicate: true }; + const now = nowIso(nowValue); + const state = ensureLiveState(database, showId, now); + const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as { + status: string; + }; + if (show.status === 'completed') throw new Error('SHOW_COMPLETED'); + if (state.revision !== expectedRevision) throw new Error('CONFLICT'); + operation(database, state, now); + const updated = database + .prepare( + `UPDATE show_live_states SET revision = revision + 1, updated_at_utc = ? + WHERE show_id = ? AND revision = ?` + ) + .run(now, showId, expectedRevision); + if (updated.changes !== 1) throw new Error('CONFLICT'); + const revision = expectedRevision + 1; + database + .prepare( + `INSERT INTO live_commands (show_id, command_id, action, result_revision, created_at_utc) + VALUES (?, ?, ?, ?, ?)` + ) + .run(showId, commandId, action, revision, now); + return { revision, duplicate: false }; + })(); +} + +function actualDurations(database: Database.Database, showId: string, now: string) { + const rows = database + .prepare( + `SELECT usage_id, started_at_utc, ended_at_utc + FROM live_usage_runs WHERE show_id = ?` + ) + .all(showId) as { usage_id: string; started_at_utc: string; ended_at_utc: string | null }[]; + const durations = new Map(); + for (const row of rows) { + const seconds = Math.max( + 0, + Math.floor( + (new Date(row.ended_at_utc ?? now).getTime() - new Date(row.started_at_utc).getTime()) / + 1000 + ) + ); + durations.set(row.usage_id, (durations.get(row.usage_id) ?? 0) + seconds); + } + return durations; +} + +function liveUsage( + usage: PieceUsage, + sectionId: string, + sectionName: string, + actualSeconds: number +) { + return { + ...usage, + sectionId, + sectionName, + actualDurationSeconds: actualSeconds, + introHtml: renderMarkdown(usage.intro), + researchHtml: renderMarkdown(usage.research), + moderationHtml: renderMarkdown(usage.moderation), + transitionHtml: renderMarkdown(usage.transition) + }; +} + +export function getLiveView(showId: string, nowValue?: Date): LiveView { + const database = getDatabase(); + const now = nowIso(nowValue); + const state = ensureLiveState(database, showId, now); + const show = getShow(showId); + if (!show) throw new Error('NOT_FOUND'); + const durations = actualDurations(database, showId, now); + const sections = show.sections.map((section) => ({ + ...section, + usages: section.usages.map((usage) => + liveUsage(usage, section.id, section.name, durations.get(usage.id) ?? 0) + ) + })); + const reserve = show.reserve.map((usage) => + liveUsage(usage, '', 'Reservebank', durations.get(usage.id) ?? 0) + ); + const usages = sections.flatMap((section) => section.usages); + const current = usages.find((usage) => usage.id === state.current_usage_id) ?? null; + const currentIndex = current ? usages.findIndex((usage) => usage.id === current.id) : -1; + const next = + usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ?? + (current ? null : (usages.find((usage) => usage.status === 'open') ?? null)); + return { + show: { + id: show.id, + title: show.title, + status: show.status, + revision: show.revision, + totalDurationSeconds: show.totalDurationSeconds + }, + liveRevision: state.revision, + startedAtUtc: state.started_at_utc, + updatedAtUtc: state.updated_at_utc, + presentationUsageId: state.presentation_usage_id, + presentationRevision: state.presentation_revision, + sections, + reserve, + current, + next, + completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped') + .length, + totalCount: usages.length + }; +} + +export function startOrResumeLive( + showId: string, + commandId: string, + expectedRevision: number, + nowValue?: Date +) { + return executeCommand( + showId, + commandId, + expectedRevision, + 'start', + (database, state, now) => { + database + .prepare( + `UPDATE shows SET status = 'live', updated_at_utc = ? + WHERE id = ? AND status != 'completed'` + ) + .run(now, showId); + database + .prepare( + `UPDATE show_live_states SET started_at_utc = COALESCE(started_at_utc, ?), ended_at_utc = NULL + WHERE show_id = ?` + ) + .run(now, showId); + const activeRun = database + .prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL') + .get(showId); + if (state.current_usage_id && activeRun) return; + const current = state.current_usage_id + ? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id) + : undefined; + const target = + current ?? orderedUsages(database, showId).find((usage) => usage.status === 'open'); + if (target) activateUsage(database, showId, target.id, now); + }, + nowValue + ); +} + +function advanceCurrent( + showId: string, + commandId: string, + expectedRevision: number, + status: 'done' | 'skipped', + nowValue?: Date +) { + return executeCommand( + showId, + commandId, + expectedRevision, + status, + (database, state, now) => { + if (!state.current_usage_id) throw new Error('NO_CURRENT'); + const ordered = orderedUsages(database, showId); + const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id); + if (currentIndex < 0) throw new Error('NOT_FOUND'); + closeActiveRun(database, showId, now); + database + .prepare('UPDATE piece_usages SET status = ? WHERE id = ?') + .run(status, state.current_usage_id); + const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open'); + if (next) activateUsage(database, showId, next.id, now); + else + database + .prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?') + .run(showId); + }, + nowValue + ); +} + +export function completeCurrent( + showId: string, + commandId: string, + expectedRevision: number, + nowValue?: Date +) { + return advanceCurrent(showId, commandId, expectedRevision, 'done', nowValue); +} + +export function skipCurrent( + showId: string, + commandId: string, + expectedRevision: number, + nowValue?: Date +) { + return advanceCurrent(showId, commandId, expectedRevision, 'skipped', nowValue); +} + +export function goBack( + showId: string, + commandId: string, + expectedRevision: number, + nowValue?: Date +) { + return executeCommand( + showId, + commandId, + expectedRevision, + 'back', + (database, state, now) => { + const ordered = orderedUsages(database, showId); + const currentIndex = state.current_usage_id + ? ordered.findIndex((usage) => usage.id === state.current_usage_id) + : ordered.length; + const previous = ordered + .slice(0, Math.max(0, currentIndex)) + .reverse() + .find((usage) => usage.status === 'done' || usage.status === 'skipped'); + if (!previous) throw new Error('NO_PREVIOUS'); + if (state.current_usage_id) { + closeActiveRun(database, showId, now); + database + .prepare(`UPDATE piece_usages SET status = 'open' WHERE id = ?`) + .run(state.current_usage_id); + } + activateUsage(database, showId, previous.id, now); + }, + nowValue + ); +} + +export function presentUsage( + showId: string, + usageId: string | null, + commandId: string, + expectedRevision: number, + nowValue?: Date +) { + return executeCommand( + showId, + commandId, + expectedRevision, + usageId ? 'present' : 'clear-presentation', + (database, _state, now) => { + if (usageId) usageBelongsToShow(database, showId, usageId); + database + .prepare( + `UPDATE show_live_states SET presentation_usage_id = ?, + presentation_revision = presentation_revision + 1, updated_at_utc = ? + WHERE show_id = ?` + ) + .run(usageId, now, showId); + }, + nowValue + ); +} + +export function moveOpenUsage( + showId: string, + usageId: string, + expectedShowRevision: number, + input: MovePieceUsageInput +) { + const database = getDatabase(); + const usage = database + .prepare( + `SELECT u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE u.id = ? AND s.show_id = ?` + ) + .get(usageId, showId) as { status: PieceUsage['status'] } | undefined; + if (!usage) throw new Error('NOT_FOUND'); + if (usage.status !== 'open') throw new Error('LIVE_ORDER_LOCKED'); + if (input.sectionId) { + const target = database + .prepare( + `SELECT u.id, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE s.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY u.position` + ) + .all(input.sectionId, showId) as { id: string; status: PieceUsage['status'] }[]; + if (!target.length) { + const section = database + .prepare('SELECT 1 FROM show_sections WHERE id = ? AND show_id = ? AND is_reserve_bank = 0') + .get(input.sectionId, showId); + if (!section) throw new Error('NOT_FOUND'); + } + const withoutMoved = target.filter((item) => item.id !== usageId); + const firstOpenPosition = withoutMoved.findIndex((item) => item.status === 'open'); + const minimum = firstOpenPosition < 0 ? withoutMoved.length : firstOpenPosition; + if (input.position < minimum) throw new Error('LIVE_ORDER_LOCKED'); + } + return movePieceUsage(showId, usageId, expectedShowRevision, input); +} + +export function issuePresentationAccess(showId: string) { + const database = getDatabase(); + const now = nowIso(); + ensureLiveState(database, showId, now); + const token = randomBytes(32).toString('base64url'); + database.transaction(() => { + database + .prepare( + 'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL' + ) + .run(now, showId); + database + .prepare( + `INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc) + VALUES (?, ?, ?, ?)` + ) + .run(randomUUID(), showId, hashToken(token), now); + })(); + return token; +} + +function presentationShowId(database: Database.Database, token: string) { + if (!token || token.length > 200) return null; + const row = database + .prepare( + `SELECT show_id FROM presentation_access_tokens + WHERE token_hash = ? AND revoked_at_utc IS NULL` + ) + .get(hashToken(token)) as { show_id: string } | undefined; + return row?.show_id ?? null; +} + +export function getPresentationView(token: string): PresentationView | null { + const database = getDatabase(); + const showId = presentationShowId(database, token); + if (!showId) return null; + const state = ensureLiveState(database, showId, nowIso()); + if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; + const usage = database + .prepare( + `SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id + FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0` + ) + .get(state.presentation_usage_id, showId) as + | { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null } + | undefined; + if (!usage) return { revision: state.presentation_revision, content: null }; + const sources = usage.original_piece_id + ? (database + .prepare( + `SELECT label, url, timecode_seconds AS timecodeSeconds + FROM piece_sources WHERE piece_id = ? ORDER BY position` + ) + .all(usage.original_piece_id) as { + label: string; + url: string; + timecodeSeconds: number | null; + }[]) + : []; + const images = usage.original_piece_id + ? (database + .prepare( + `SELECT id, alt_text AS altText FROM piece_media + WHERE piece_id = ? ORDER BY position` + ) + .all(usage.original_piece_id) as { id: string; altText: string }[]) + : []; + return { + revision: state.presentation_revision, + content: { + title: usage.title_snapshot, + html: renderMarkdown(usage.presentation_snapshot), + sources, + images + } + }; +} + +export async function getPresentationMedia(token: string, mediaId: string) { + const database = getDatabase(); + const showId = presentationShowId(database, token); + if (!showId) return null; + const allowed = database + .prepare( + `SELECT 1 FROM show_live_states l + JOIN piece_usages u ON u.id = l.presentation_usage_id + JOIN show_sections s ON s.id = u.show_section_id + JOIN piece_media m ON m.piece_id = u.original_piece_id + WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?` + ) + .get(showId, showId, mediaId); + if (!allowed) return null; + return getMediaFile(mediaId); +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index d8083fd..6e6db09 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,3 +1,6 @@ import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user }); +export const load: LayoutServerLoad = ({ locals, url }) => ({ + user: url.pathname.startsWith('/heinzlive/') ? null : locals.user, + presentationMode: url.pathname.startsWith('/heinzlive/') +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d0fea4c..3e2b122 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -31,7 +31,9 @@ /> -{#if data.user} +{#if data.presentationMode} + {@render children()} +{:else if data.user}
{/if}
diff --git a/src/routes/sendungen/[id]/+page.svelte b/src/routes/sendungen/[id]/+page.svelte index d5574c8..23c7b39 100644 --- a/src/routes/sendungen/[id]/+page.svelte +++ b/src/routes/sendungen/[id]/+page.svelte @@ -175,7 +175,7 @@ {formatDuration(data.show.totalDurationSeconds)}Planzeit Live-Vorschau ↗Live-Steuerung ↗ diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index 5d80f02..96d7809 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -486,22 +486,108 @@ test('inbox, archive and administration stay concise', async ({ page }) => { expect(boxes[1]?.y).toBe(boxes[2]?.y); }); -test('live preview retains its existing readable controls', async ({ page }) => { +test('live control persists state and exposes only explicit presentation data', async ({ + page, + browser +}, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('/live'); + await page.goto('/fundus?q=kleine+Konsole'); + await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click(); + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ); + await page.getByLabel('Bild', { exact: true }).setInputFiles({ + name: 'live-konsole.png', + mimeType: 'image/png', + buffer: png + }); + await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); + await page.getByRole('button', { name: 'Bild hochladen' }).click(); + + await page.goto('/live?show=show-october'); + await expect(page.getByText('Noch nicht gestartet.')).toBeVisible(); + await page.getByRole('button', { name: 'Sendung starten' }).click(); + await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); + await expect( + page.getByText('Herstellerangaben gegen unabhängige Tests prüfen') + ).not.toBeVisible(); - await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible(); - await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible(); const before = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); - const after = await page + await page.reload(); + const restored = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); - expect(after).not.toBe(before); - await page.getByText('Recherche öffnen').click(); + expect(restored).not.toBe(before); + await page.getByText('Manuelle Recherche öffnen').click(); await expect(page.locator('.research-details')).toHaveAttribute('open', ''); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click() + ]); + await popup.waitForURL(/\/heinzlive\/[A-Za-z0-9_-]+$/); + const outputUrl = popup.url(); + const anonymous = await browser.newContext(); + const output = await anonymous.newPage(); + await output.goto(outputUrl); + await expect( + output.getByRole('heading', { name: 'Bereit für den nächsten Inhalt' }) + ).toBeVisible(); + + await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); + await expect( + output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) + ).toBeVisible({ + timeout: 10_000 + }); + await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible(); + await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible(); + await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true }); + const outputHtml = await output.content(); + expect(outputHtml).not.toContain('Herstellerangaben'); + expect(outputHtml).not.toContain('Erst Formfaktor zeigen'); + expect(outputHtml).not.toContain(adminEmail); + expect(outputHtml).not.toContain('live-konsole.png'); + const outputState = await anonymous.request.get(`${outputUrl}/state`); + expect(outputState.status()).toBe(200); + const outputJson = await outputState.text(); + expect(outputJson).toContain('Weniger Leistung'); + expect(outputJson).not.toContain('Herstellerangaben'); + expect(outputJson).not.toContain('Moderationsstichpunkte'); + expect(outputJson).not.toContain('live-konsole.png'); + + await page.getByRole('button', { name: 'Als Nächstes' }).click(); + await expect(page.locator('.next-card')).toContainText('Pixelart'); + await page.getByRole('button', { name: 'Erledigen und weiter' }).click(); + await expect(page.locator('.live-current')).toContainText('Pixelart'); + await page.getByRole('button', { name: 'Überspringen' }).click(); + await page.getByRole('button', { name: 'Zurück' }).click(); + await expect(page.locator('.live-current')).toContainText('Pixelart'); + await output.route('**/state', (route) => route.abort()); + await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 }); + await output.unroute('**/state'); + await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 }); + await page.setViewportSize({ width: 390, height: 844 }); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('live-controller-mobile.png'), + fullPage: true + }); + await page.setViewportSize({ width: 1280, height: 900 }); + + await page.evaluate(() => { + window.open = () => null; + }); + await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click(); + await expect( + page.getByText('Popup blockiert. Bitte Popups erlauben und erneut öffnen.') + ).toBeVisible(); + await anonymous.close(); + await popup.close(); await expectNoHorizontalOverflow(page); });