1 Commits
Author SHA1 Message Date
hnzio 5a9d7735d4 Release 0.5.0 2026-10-22 00:28:49 +02:00
26 changed files with 1855 additions and 113 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.4.0", "version": "0.5.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+24 -3
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
Etappe 1 bis 4: technisches Grundgerüst, Anmeldung und Rollen, einen Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen
persistenten Fundus sowie den vollständigen Sendungsbaukasten. Live-Steuerung persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine
und Präsentationsausgabe folgen in späteren Etappen. abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6.
## Voraussetzungen ## 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 kopierbar. Gleichzeitige Änderungen werden über die Revision der gesamten
Sendung erkannt und nicht still überschrieben. Sendung erkannt und nicht still überschrieben.
## Live moderieren und präsentieren
Die Live-Steuerung öffnet sich aus einer geplanten Sendung. Sie zeigt
Einleitung, Moderationsstichpunkte und Überleitung in einstellbarer
Prompter-Schrift; die private Recherche bleibt standardmäßig geschlossen.
Zeiten und Ablaufleiste können ausgeblendet werden. Schriftgröße, Sichtbarkeit
und Leseposition bleiben im Browser erhalten.
Start, Erledigen, Überspringen und Zurück werden persistent mit tatsächlichen
Zeitintervallen gespeichert. Wiederholte Befehle erzeugen keine doppelte
Zeitbuchung. Offene Pieces und die Reserve lassen sich während der Sendung
sortieren beziehungsweise als nächster Beitrag einfügen.
Das separate Präsentationsfenster unter `/heinzlive/...` verwendet einen langen,
widerrufbaren Zufallstoken. Es ändert sich ausschließlich durch die Aktion
**Präsentieren** und erhält nur Präsentationstext, öffentliche Quellen und die
zum präsentierten Piece gehörenden Bilder. Recherche, Moderationsnotizen,
eigene Notizen, Kontodaten und interne Dateipfade werden weder im HTML noch im
JSON-Zustand ausgeliefert. Der Token ist ein Zugangsgeheimnis und darf nicht
weitergegeben oder protokolliert werden.
## Befehle ## Befehle
| Befehl | Zweck | | Befehl | Zweck |
+28
View File
@@ -120,3 +120,31 @@
ungewollten horizontalen Überlauf ungewollten horizontalen Überlauf
- Mobile Aufnahme des vollständigen Sendungsbaukastens visuell kontrolliert - Mobile Aufnahme des vollständigen Sendungsbaukastens visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich - `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
+32 -8
View File
@@ -33,8 +33,9 @@ spätere API dieselben Regeln verwenden.
## Gesamtdatenmodell ## Gesamtdatenmodell
Die ersten vier Etappen materialisieren Identität, Inhaltsverwaltung und Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche. Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
@@ -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 Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst
beim Loslassen. beim Loslassen.
## Live-Modus und Präsentationsgrenze
`src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der
Live-Stand besitzt eine eigene Revision. Jeder steuernde Befehl trägt zusätzlich
eine vom Browser erzeugte Befehls-ID; `(show_id, command_id)` ist eindeutig und
liefert bei Wiederholung das bereits gespeicherte Ergebnis. Tatsächliche Zeiten
liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
geplanten Dauer nachvollziehbar.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe
`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation
oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein
neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht.
Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per
`no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler
sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
@@ -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 stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht. Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
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.
## Sicherheitsgrenzen ab den Folgeetappen ## Sicherheitsgrenzen ab den Folgeetappen
+23
View File
@@ -202,3 +202,26 @@ Quellen:
- <https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements> - <https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements>
- <https://svelte.dev/docs/kit/form-actions> - <https://svelte.dev/docs/kit/form-actions>
## D-016: Live-Befehle idempotent, tatsächliche Zeiten als Intervalle
Die Live-Steuerung verwendet eine eigene Revision und zusätzlich eine pro
Bedienaktion erzeugte Befehls-ID. Die Datenbank akzeptiert jede Kombination aus
Sendung und Befehls-ID nur einmal. Ein nach Verbindungsabbruch wiederholter
Befehl liefert dieselbe Ergebnisrevision und erzeugt weder einen zweiten
Statuswechsel noch ein zweites Zeitintervall. Laufzeit wird nicht als einzelner
überschreibbarer Wert, sondern als Summe abgeschlossener und höchstens eines
aktiven Intervalls ermittelt. Dadurch bleiben Zurückgehen und Wiederaufnahme
korrekt.
## D-017: Präsentationsausgabe als Positivliste mit gehashtem Zugang
Die heinzlive-Ausgabe ist kein eingeschränkter Aufruf der internen Live-Ansicht,
sondern eine eigene Datenprojektion. Zulässig sind ausschließlich ausdrücklich
präsentierter Titel und Präsentationstext, öffentliche Quellen sowie zugehörige
Bilder und Alternativtexte. Recherche, Einleitung, Moderation, eigene Notizen,
Ablauf, Nutzer und interne Dateinamen werden nicht in HTML oder JSON übernommen.
Ein zufälliger 256-Bit-Token steht in der URL, während SQLite nur seinen
SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und
Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`-
Polling statt über eine dauerhaft offene Serververbindung.
+28 -6
View File
@@ -2,9 +2,10 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 bis Etappe 4 sind abgeschlossen. Architektur, Gesamtdatenmodell, Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung und lokaler Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Betriebsweg sind dokumentiert. Etappe 5 wurde nicht begonnen. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1 ## Umgesetzter Stand Etappe 1
@@ -37,9 +38,8 @@ umgebrochen.
## Noch nicht umgesetzt ## Noch nicht umgesetzt
Echte Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und Archivabschluss, Freigaben, RSS und API. Diese späteren Bereiche dürfen nicht
API. Die verbleibenden Vorschauen dürfen nicht als als bereits implementiert verstanden werden.
Implementierung dieser späteren Funktionen verstanden werden.
## Umgesetzter Stand Etappe 2 ## Umgesetzter Stand Etappe 2
@@ -90,6 +90,23 @@ Implementierung dieser späteren Funktionen verstanden werden.
vor unbemerktem parallelem Überschreiben vor unbemerktem parallelem Überschreiben
- realistischer Seed mit Standardablauf und eigenständigem Spieleabend - realistischer Seed mit Standardablauf und eigenständigem Spieleabend
## Umgesetzter Stand Etappe 5
- fokussierte Moderationsansicht mit Prompter-Schrift, Einleitung, Moderation,
Überleitung und standardmäßig geschlossener privater Recherche
- ausblendbare Zeit- und Ablaufanzeige sowie persistente lokale Leseposition
- aktuelles und nächstes Piece samt Abschnitt und geplantem Zeitbedarf
- persistenter Live-Stand mit tatsächlichen Laufintervallen, Erledigen,
Überspringen, Zurück und Wiederaufnahme
- idempotente Live-Befehle verhindern doppelte Wirkung bei Wiederholung
- serverseitig geschützte Umsortierung offener Pieces und Reserveeinfügung
- explizite Präsentationsaktion; normale Live-Navigation verändert die Ausgabe
nicht
- getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige,
Präsentationstext, Quellen und erlaubten Bildern
- Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche,
Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade
## Bekannte offene Betriebsprüfung ## Bekannte offene Betriebsprüfung
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
@@ -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 auf der Prüfmaschine kein Docker-Daemon lief. Manifestkernfelder und
Startskript-Syntax wurden lokal geprüft. Startskript-Syntax wurden lokal geprüft.
Eine reale OBS-Browserquelle stand nicht zur Verfügung. Die tokenisierte
Präsentationsausgabe wurde stattdessen in einem anonymen zweiten Chromium-
Browserkontext einschließlich Bild, JSON-Filterung, Polling und sichtbarem
Verbindungsabbruch geprüft.
## Verifizierte Umgebung ## Verifizierte Umgebung
- Node.js 22.23.1, npm 10.9.8 - Node.js 22.23.1, npm 10.9.8
+45
View File
@@ -0,0 +1,45 @@
CREATE TABLE show_live_states (
show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
current_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL,
started_at_utc TEXT,
ended_at_utc TEXT,
presentation_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL,
presentation_revision INTEGER NOT NULL DEFAULT 0 CHECK (presentation_revision >= 0),
updated_at_utc TEXT NOT NULL
);
CREATE TABLE live_usage_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE UNIQUE INDEX idx_one_active_live_run_per_show
ON live_usage_runs(show_id) WHERE ended_at_utc IS NULL;
CREATE INDEX idx_live_runs_usage ON live_usage_runs(usage_id, started_at_utc);
CREATE TABLE live_commands (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
action TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 0),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
CREATE TABLE presentation_access_tokens (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at_utc TEXT NOT NULL,
revoked_at_utc TEXT
);
CREATE INDEX idx_presentation_tokens_show
ON presentation_access_tokens(show_id, revoked_at_utc);
UPDATE piece_usages SET status = 'open' WHERE status = 'current';
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.4.0", "version": "0.5.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+1 -1
View File
@@ -348,7 +348,7 @@ export function seed(databasePath = getPaths().database) {
'section-stories', 'section-stories',
'piece-console', 'piece-console',
0, 0,
'current', 'open',
480, 480,
'Die kleine Konsole mit dem großen Dreh', 'Die kleine Konsole mit dem großen Dreh',
'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?', 'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?',
+267
View File
@@ -1101,6 +1101,273 @@ a {
font-size: 0.61rem; font-size: 0.61rem;
} }
.live-state-line {
margin: 0.4rem 0 0;
color: var(--text-muted);
font-size: 0.75rem;
}
.live-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
}
.live-session-summary {
display: grid;
margin-left: auto;
text-align: right;
}
.live-session-summary strong {
font-size: 0.78rem;
}
.live-session-summary span {
color: var(--text-muted);
font-size: 0.65rem;
}
.live-grid.without-rail {
grid-template-columns: minmax(0, 1fr);
}
.live-markdown > :first-child,
.prompter-copy > :first-child {
margin-top: 0;
}
.live-markdown > :last-child,
.prompter-copy > :last-child {
margin-bottom: 0;
}
.live-current-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.4rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
}
.live-current-actions form:last-child {
margin-left: auto;
}
.live-run-item,
.live-reserve-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
margin-top: 0.35rem;
padding: 0.5rem 0.55rem;
border-radius: 0.65rem;
background: var(--panel-soft);
}
.live-run-item.current {
box-shadow: inset 3px 0 var(--accent);
background: var(--accent-soft);
}
.live-run-item > div:first-child,
.live-reserve-item > div:first-child {
display: grid;
min-width: 0;
}
.live-run-item strong,
.live-reserve-item strong {
font-size: 0.7rem;
overflow-wrap: anywhere;
}
.live-run-item small,
.live-reserve-item small {
color: var(--text-muted);
font-size: 0.61rem;
}
.live-run-actions {
display: flex;
align-items: center;
gap: 0.2rem;
}
.live-run-actions form {
display: flex;
}
.live-run-actions button:not(.text-button) {
min-width: 1.65rem;
min-height: 1.65rem;
padding: 0;
}
.live-run-actions .text-button {
padding: 0.3rem;
font-size: 0.62rem;
}
.live-reserve {
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line));
border-radius: 1rem;
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
}
.live-reserve > header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
font-weight: 720;
}
.live-reserve > p {
margin: 0.65rem 0 0;
color: var(--text-muted);
font-size: 0.68rem;
}
.heinzlive-output {
min-height: 100vh;
padding: clamp(2rem, 6vw, 6rem);
background:
radial-gradient(circle at 12% 15%, rgb(103 221 232 / 12%), transparent 32rem), #0b1220;
color: #fff7e8;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.output-connection {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.65rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 2rem;
background: rgb(11 18 32 / 78%);
color: rgb(255 247 232 / 68%);
font-size: 0.68rem;
backdrop-filter: blur(10px);
}
.output-connection span {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: #67dde8;
}
.output-connection.problem {
color: #ffd0cb;
}
.output-connection.problem span {
background: #ff776d;
}
.output-content {
display: grid;
max-width: 88rem;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
align-content: center;
margin: 0 auto;
}
.output-kicker,
.output-sources > span {
color: #67dde8;
font-size: clamp(0.68rem, 1.2vw, 0.9rem);
font-weight: 760;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.output-content h1,
.output-idle h1 {
max-width: 16ch;
margin: 0.45rem 0 1.5rem;
font-size: clamp(2.8rem, 8vw, 8rem);
letter-spacing: -0.055em;
line-height: 0.94;
}
.output-copy {
max-width: 44ch;
font-family: ui-serif, Georgia, serif;
font-size: clamp(1.4rem, 2.8vw, 2.7rem);
line-height: 1.35;
}
.output-copy a,
.output-sources a {
color: #e7b957;
}
.output-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 1rem;
margin-top: 2rem;
}
.output-images img {
width: 100%;
max-height: 48vh;
object-fit: contain;
border-radius: 1.2rem;
background: rgb(255 255 255 / 5%);
}
.output-sources {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid rgb(255 255 255 / 14%);
}
.output-sources a {
font-size: 0.85rem;
}
.output-idle {
display: grid;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
place-content: center;
text-align: center;
}
.output-idle .brand-mark {
margin: 0 auto 1.5rem;
transform: scale(1.5);
}
.output-idle h1 {
max-width: 18ch;
font-size: clamp(2.4rem, 6vw, 5.5rem);
}
.output-idle p {
margin: 0;
color: rgb(255 247 232 / 62%);
font-size: clamp(0.9rem, 1.8vw, 1.3rem);
}
.quiet-panel { .quiet-panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-large); border-radius: var(--radius-large);
+1
View File
@@ -7,6 +7,7 @@ declare global {
} }
interface PageData { interface PageData {
user?: AuthUser | null; user?: AuthUser | null;
presentationMode?: boolean;
} }
} }
} }
+6 -1
View File
@@ -13,11 +13,12 @@ const publicPaths = new Set([
]); ]);
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies); if (token && !event.locals.user) deleteSession(event.cookies);
if (!publicPaths.has(event.url.pathname) && !event.locals.user) { if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) {
redirect(303, hasUsers() ? '/login' : '/setup'); redirect(303, hasUsers() ? '/login' : '/setup');
} }
@@ -25,6 +26,10 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
if (presentationPath) {
response.headers.set('Cache-Control', 'no-store');
response.headers.set('Referrer-Policy', 'no-referrer');
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
} }
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 4, migrations: 5,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+137
View File
@@ -0,0 +1,137 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
getLiveView,
getPresentationMedia,
getPresentationView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
skipCurrent,
startOrResumeLive
} from './live.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-live-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('live mode', () => {
it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started);
expect(initial.current).toBeNull();
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
revision: 1,
duplicate: false
});
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
revision: 1,
duplicate: true
});
let live = getLiveView('show-october', new Date('2026-10-22T18:05:00.000Z'));
expect(live.current?.id).toBe('usage-console');
expect(live.current?.actualDurationSeconds).toBe(300);
expect(() =>
moveOpenUsage('show-october', 'usage-effects', live.show.revision, {
sectionId: 'section-stories',
position: 0
})
).toThrow('LIVE_ORDER_LOCKED');
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:06:00.000Z'));
expect(live.current?.id).toBe('usage-effects');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')
).toMatchObject({ status: 'done', actualDurationSeconds: 300 });
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get('usage-console')
).toEqual({ count: 1 });
expect(() => skipCurrent('show-october', 'stale-command', 1)).toThrow('CONFLICT');
skipCurrent('show-october', 'skip-effects', 2, new Date('2026-10-22T18:06:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:07:00.000Z'));
expect(live.current).toBeNull();
expect(live.completedCount).toBe(2);
goBack('show-october', 'back-effects', 3, new Date('2026-10-22T18:07:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:08:00.000Z'));
expect(live.current).toMatchObject({ id: 'usage-effects', status: 'current' });
expect(live.current?.actualDurationSeconds).toBe(120);
});
it('changes presentation only explicitly and exposes no private fields', async () => {
const uploads = process.env.SENDEWERK_UPLOAD_DIR!;
mkdirSync(uploads, { recursive: true });
writeFileSync(path.join(uploads, 'presentation.png'), Buffer.from('test-image'));
getDatabase()
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
'presentation-image',
'piece-console',
'presentation.png',
'private-original-name.png',
'image/png',
10,
'Konsole auf dem Tisch',
0,
'2026-10-22T18:00:00.000Z'
);
const token = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
startOrResumeLive('show-october', 'start-output', 0);
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
presentUsage('show-october', 'usage-console', 'present-console', 1);
const output = getPresentationView(token)!;
expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
});
const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
expect(serialized).not.toContain('Herstellerangaben');
expect(serialized).not.toContain('Moderationsstichpunkte');
expect(serialized).not.toContain('private-original-name');
expect(serialized).not.toContain('presentation.png');
const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image');
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toBeNull();
expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
});
});
+563
View File
@@ -0,0 +1,563 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import {
getShow,
movePieceUsage,
type MovePieceUsageInput,
type PieceUsage,
type ShowSection
} from './shows.js';
type LiveStateRow = {
show_id: string;
revision: number;
current_usage_id: string | null;
started_at_utc: string | null;
ended_at_utc: string | null;
presentation_usage_id: string | null;
presentation_revision: number;
updated_at_utc: string;
};
type OrderedUsageRow = {
id: string;
section_id: string;
section_name: string;
section_position: number;
position: number;
status: PieceUsage['status'];
};
export type LiveUsage = PieceUsage & {
sectionId: string;
sectionName: string;
actualDurationSeconds: number;
introHtml: string;
researchHtml: string;
moderationHtml: string;
transitionHtml: string;
};
export type LiveSection = Omit<ShowSection, 'usages'> & { usages: LiveUsage[] };
export type LiveView = {
show: {
id: string;
title: string;
status: string;
revision: number;
totalDurationSeconds: number;
};
liveRevision: number;
startedAtUtc: string | null;
updatedAtUtc: string;
presentationUsageId: string | null;
presentationRevision: number;
sections: LiveSection[];
reserve: LiveUsage[];
current: LiveUsage | null;
next: LiveUsage | null;
completedCount: number;
totalCount: number;
};
export type PresentationView = {
revision: number;
content: null | {
title: string;
html: string;
sources: { label: string; url: string; timecodeSeconds: number | null }[];
images: { id: string; altText: string }[];
};
};
function hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
function nowIso(now?: Date) {
return (now ?? new Date()).toISOString();
}
function requireCommandId(commandId: string) {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
}
function ensureLiveState(database: Database.Database, showId: string, now: string): LiveStateRow {
const show = database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId);
if (!show) throw new Error('NOT_FOUND');
database
.prepare(
`INSERT OR IGNORE INTO show_live_states (show_id, updated_at_utc)
VALUES (?, ?)`
)
.run(showId, now);
return database
.prepare('SELECT * FROM show_live_states WHERE show_id = ?')
.get(showId) as LiveStateRow;
}
function orderedUsages(database: Database.Database, showId: string): OrderedUsageRow[] {
return database
.prepare(
`SELECT u.id, s.id AS section_id, s.name AS section_name,
s.position AS section_position, u.position, u.status
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0
ORDER BY s.position, u.position`
)
.all(showId) as OrderedUsageRow[];
}
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database
.prepare(
`SELECT s.is_reserve_bank FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
}
function closeActiveRun(database: Database.Database, showId: string, now: string) {
database
.prepare(
`UPDATE live_usage_runs SET ended_at_utc = ?
WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, showId);
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
usageBelongsToShow(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET status = 'open'
WHERE status = 'current' AND id != ? AND id IN (
SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ?
)`
)
.run(usageId, showId);
database.prepare(`UPDATE piece_usages SET status = 'current' WHERE id = ?`).run(usageId);
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, usageId, now);
database
.prepare(
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
)
.run(usageId, showId);
}
function executeCommand(
showId: string,
commandId: string,
expectedRevision: number,
action: string,
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
nowValue?: Date
) {
requireCommandId(commandId);
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare('SELECT result_revision FROM live_commands WHERE show_id = ? AND command_id = ?')
.get(showId, commandId) as { result_revision: number } | undefined;
if (duplicate) return { revision: duplicate.result_revision, duplicate: true };
const now = nowIso(nowValue);
const state = ensureLiveState(database, showId, now);
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string;
};
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now);
const updated = database
.prepare(
`UPDATE show_live_states SET revision = revision + 1, updated_at_utc = ?
WHERE show_id = ? AND revision = ?`
)
.run(now, showId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const revision = expectedRevision + 1;
database
.prepare(
`INSERT INTO live_commands (show_id, command_id, action, result_revision, created_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(showId, commandId, action, revision, now);
return { revision, duplicate: false };
})();
}
function actualDurations(database: Database.Database, showId: string, now: string) {
const rows = database
.prepare(
`SELECT usage_id, started_at_utc, ended_at_utc
FROM live_usage_runs WHERE show_id = ?`
)
.all(showId) as { usage_id: string; started_at_utc: string; ended_at_utc: string | null }[];
const durations = new Map<string, number>();
for (const row of rows) {
const seconds = Math.max(
0,
Math.floor(
(new Date(row.ended_at_utc ?? now).getTime() - new Date(row.started_at_utc).getTime()) /
1000
)
);
durations.set(row.usage_id, (durations.get(row.usage_id) ?? 0) + seconds);
}
return durations;
}
function liveUsage(
usage: PieceUsage,
sectionId: string,
sectionName: string,
actualSeconds: number
) {
return {
...usage,
sectionId,
sectionName,
actualDurationSeconds: actualSeconds,
introHtml: renderMarkdown(usage.intro),
researchHtml: renderMarkdown(usage.research),
moderationHtml: renderMarkdown(usage.moderation),
transitionHtml: renderMarkdown(usage.transition)
};
}
export function getLiveView(showId: string, nowValue?: Date): LiveView {
const database = getDatabase();
const now = nowIso(nowValue);
const state = ensureLiveState(database, showId, now);
const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND');
const durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({
...section,
usages: section.usages.map((usage) =>
liveUsage(usage, section.id, section.name, durations.get(usage.id) ?? 0)
)
}));
const reserve = show.reserve.map((usage) =>
liveUsage(usage, '', 'Reservebank', durations.get(usage.id) ?? 0)
);
const usages = sections.flatMap((section) => section.usages);
const current = usages.find((usage) => usage.id === state.current_usage_id) ?? null;
const currentIndex = current ? usages.findIndex((usage) => usage.id === current.id) : -1;
const next =
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
return {
show: {
id: show.id,
title: show.title,
status: show.status,
revision: show.revision,
totalDurationSeconds: show.totalDurationSeconds
},
liveRevision: state.revision,
startedAtUtc: state.started_at_utc,
updatedAtUtc: state.updated_at_utc,
presentationUsageId: state.presentation_usage_id,
presentationRevision: state.presentation_revision,
sections,
reserve,
current,
next,
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
.length,
totalCount: usages.length
};
}
export function startOrResumeLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'start',
(database, state, now) => {
database
.prepare(
`UPDATE shows SET status = 'live', updated_at_utc = ?
WHERE id = ? AND status != 'completed'`
)
.run(now, showId);
database
.prepare(
`UPDATE show_live_states SET started_at_utc = COALESCE(started_at_utc, ?), ended_at_utc = NULL
WHERE show_id = ?`
)
.run(now, showId);
const activeRun = database
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId);
if (state.current_usage_id && activeRun) return;
const current = state.current_usage_id
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
: undefined;
const target =
current ?? orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now);
},
nowValue
);
}
function advanceCurrent(
showId: string,
commandId: string,
expectedRevision: number,
status: 'done' | 'skipped',
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
status,
(database, state, now) => {
if (!state.current_usage_id) throw new Error('NO_CURRENT');
const ordered = orderedUsages(database, showId);
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
if (currentIndex < 0) throw new Error('NOT_FOUND');
closeActiveRun(database, showId, now);
database
.prepare('UPDATE piece_usages SET status = ? WHERE id = ?')
.run(status, state.current_usage_id);
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
if (next) activateUsage(database, showId, next.id, now);
else
database
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
.run(showId);
},
nowValue
);
}
export function completeCurrent(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return advanceCurrent(showId, commandId, expectedRevision, 'done', nowValue);
}
export function skipCurrent(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return advanceCurrent(showId, commandId, expectedRevision, 'skipped', nowValue);
}
export function goBack(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'back',
(database, state, now) => {
const ordered = orderedUsages(database, showId);
const currentIndex = state.current_usage_id
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
: ordered.length;
const previous = ordered
.slice(0, Math.max(0, currentIndex))
.reverse()
.find((usage) => usage.status === 'done' || usage.status === 'skipped');
if (!previous) throw new Error('NO_PREVIOUS');
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare(`UPDATE piece_usages SET status = 'open' WHERE id = ?`)
.run(state.current_usage_id);
}
activateUsage(database, showId, previous.id, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
usageId ? 'present' : 'clear-presentation',
(database, _state, now) => {
if (usageId) usageBelongsToShow(database, showId, usageId);
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = ?,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ?`
)
.run(usageId, now, showId);
},
nowValue
);
}
export function moveOpenUsage(
showId: string,
usageId: string,
expectedShowRevision: number,
input: MovePieceUsageInput
) {
const database = getDatabase();
const usage = database
.prepare(
`SELECT u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { status: PieceUsage['status'] } | undefined;
if (!usage) throw new Error('NOT_FOUND');
if (usage.status !== 'open') throw new Error('LIVE_ORDER_LOCKED');
if (input.sectionId) {
const target = database
.prepare(
`SELECT u.id, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY u.position`
)
.all(input.sectionId, showId) as { id: string; status: PieceUsage['status'] }[];
if (!target.length) {
const section = database
.prepare('SELECT 1 FROM show_sections WHERE id = ? AND show_id = ? AND is_reserve_bank = 0')
.get(input.sectionId, showId);
if (!section) throw new Error('NOT_FOUND');
}
const withoutMoved = target.filter((item) => item.id !== usageId);
const firstOpenPosition = withoutMoved.findIndex((item) => item.status === 'open');
const minimum = firstOpenPosition < 0 ? withoutMoved.length : firstOpenPosition;
if (input.position < minimum) throw new Error('LIVE_ORDER_LOCKED');
}
return movePieceUsage(showId, usageId, expectedShowRevision, input);
}
export function issuePresentationAccess(showId: string) {
const database = getDatabase();
const now = nowIso();
ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url');
database.transaction(() => {
database
.prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
)
.run(now, showId);
database
.prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, hashToken(token), now);
})();
return token;
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const row = database
.prepare(
`SELECT show_id FROM presentation_access_tokens
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(hashToken(token)) as { show_id: string } | undefined;
return row?.show_id ?? null;
}
export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(state.presentation_usage_id, showId) as
| { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null }
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources = usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images = usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
title: usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
sources,
images
}
};
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const allowed = database
.prepare(
`SELECT 1 FROM show_live_states l
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
}
+4 -1
View File
@@ -1,3 +1,6 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user }); export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/')
});
+3 -1
View File
@@ -31,7 +31,9 @@
/> />
</svelte:head> </svelte:head>
{#if data.user} {#if data.presentationMode}
{@render children()}
{:else if data.user}
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite"> <a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPresentationView } from '#lib/server/live.js';
export const load: PageServerLoad = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return { view };
};
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let view = $state(untrack(() => data.view));
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected');
onMount(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
try {
const response = await fetch(`/heinzlive/${page.params.token}/state`, {
headers: { Accept: 'application/json' },
cache: 'no-store'
});
if (!response.ok) throw new Error('STATE_UNAVAILABLE');
const next = await response.json();
if (next.revision !== view.revision) view = next;
connection = 'connected';
} catch {
connection = 'interrupted';
}
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
}
connection = 'waiting';
timer = setTimeout(refresh, 500);
return () => {
stopped = true;
clearTimeout(timer);
};
});
</script>
<svelte:head>
<title>heinzlive</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class="heinzlive-output">
<div class="output-connection" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div>
{#if view.content}
<article class="output-content">
<p class="output-kicker">heinzlive</p>
<h1>{view.content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html view.content.html}</div>
{#if view.content.images.length}<div class="output-images">
{#each view.content.images as image (image.id)}
<img src={`/heinzlive/${page.params.token}/media/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if view.content.sources.length}<footer class="output-sources">
<span>Quellen</span>
{#each view.content.sources as source, index (`${index}-${source.url}`)}
<!-- Source URLs are validated as absolute HTTP(S) URLs when the Piece is saved. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else}
<section class="output-idle">
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
<h1>Bereit für den nächsten Inhalt</h1>
<p>Die Moderation wählt ausdrücklich aus, was hier erscheint.</p>
</section>
{/if}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationMedia } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const media = await getPresentationMedia(params.token, params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -0,0 +1,9 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationView } from '#lib/server/live.js';
export const GET: RequestHandler = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } });
};
+125 -13
View File
@@ -1,15 +1,127 @@
import { getShow } from '#lib/server/previews.js'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
completeCurrent,
getLiveView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
skipCurrent,
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
export function load({ url }) { function text(data: FormData, name: string) {
const show = getShow(url.searchParams.get('show') || undefined); return String(data.get(name) || '');
const usages = show.sections.flatMap((section) =>
section.usages
.filter((usage) => !usage.isReserve)
.map((usage) => ({ ...usage, section: section.name }))
);
const currentIndex = Math.max(
0,
usages.findIndex((usage) => usage.status === 'current')
);
return { show, current: usages[currentIndex] ?? null, next: usages[currentIndex + 1] ?? null };
} }
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
status: 409,
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
}
function attempt(intent: string, operation: () => unknown) {
try {
return { intent, success: true, result: operation() };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent, ...problem });
}
}
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
try {
return { live: getLiveView(showId), commandId: randomUUID() };
} catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden');
throw cause;
}
};
export const actions = {
start: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('start', () =>
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
complete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('complete', () =>
completeCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
skip: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('skip', () =>
skipCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
back: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('back', () =>
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
present: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('present', () =>
presentUsage(
text(data, 'showId'),
text(data, 'usageId') || null,
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
moveUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveUsage', () =>
moveOpenUsage(text(data, 'showId'), text(data, 'usageId'), Number(data.get('showRevision')), {
sectionId: text(data, 'sectionId') || null,
position: Number(data.get('position'))
})
);
},
createOutput: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const token = issuePresentationAccess(text(data, 'showId'));
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem });
}
}
} satisfies Actions;
+359 -66
View File
@@ -1,96 +1,389 @@
<script lang="ts"> <script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte'; import { enhance, type SubmitFunction } from '$app/forms';
import { formatClock } from '#lib/format.js';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PageData } from './$types'; import { onMount, untrack } from 'svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props(); let { data, form }: PageProps = $props();
let promptSize = $state(1.55); let promptSize = $state(1.55);
let showRail = $state(true);
let showTimes = $state(true);
let popupMessage = $state('');
let now = $state(Date.now());
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let outputWindow: Window | null = null;
let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
$effect(() => {
const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId) {
currentId = nextCurrentId;
loadedAt = Date.now();
}
});
onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`;
try {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
if (typeof settings.scrollY === 'number')
requestAnimationFrame(() => scrollTo(0, settings.scrollY));
} catch {
localStorage.removeItem(key);
}
const interval = setInterval(() => (now = Date.now()), 1000);
const persistScroll = () => persistSettings();
addEventListener('scroll', persistScroll, { passive: true });
return () => {
clearInterval(interval);
removeEventListener('scroll', persistScroll);
};
});
function persistSettings() {
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({ promptSize, showRail, showTimes, scrollY })
);
}
function changePromptSize(delta: number) {
promptSize = Math.min(2.4, Math.max(1.1, promptSize + delta));
persistSettings();
}
function toggleRail() {
showRail = !showRail;
persistSettings();
}
function toggleTimes() {
showTimes = !showTimes;
persistSettings();
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) {
command.value = crypto.randomUUID();
formData.set('commandId', command.value);
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ update }) => {
await update({ reset: false, invalidateAll: true });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
const enhanceOutput: SubmitFunction = ({ submitter }) => {
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') {
const response = result.data as { outputUrl: string };
if (outputWindow && !outputWindow.closed) {
outputWindow.location.href = response.outputUrl;
popupMessage = 'Präsentationsfenster verbunden.';
} else {
popupMessage = `Präsentationsfenster konnte nicht geöffnet werden: ${response.outputUrl}`;
}
} else {
outputWindow?.close();
}
await update({ reset: false, invalidateAll: false });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
</script> </script>
<svelte:head><title>Live-Vorschau | Sendewerk</title></svelte:head> <svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
<div class="page-wrap live-page"> <div class="page-wrap live-page live-controller">
<header class="live-header"> <header class="live-header">
<div> <div>
<p class="eyebrow"><span class="live-pulse"></span> Live-Modus · Vorschau</p> <p class="eyebrow"><span class="live-pulse"></span> Live-Steuerung</p>
<h1>{data.show.title}</h1> <h1>{data.live.show.title}</h1>
<p class="live-state-line">
{data.live.startedAtUtc
? 'Live-Stand wird automatisch gespeichert.'
: 'Noch nicht gestartet.'}
</p>
</div> </div>
<div class="live-header-actions"> <div class="live-header-actions">
<div class="font-control" aria-label="Prompter-Schriftgröße"> <div class="font-control" aria-label="Prompter-Schriftgröße">
<button <button
type="button" type="button"
onclick={() => (promptSize = Math.max(1.15, promptSize - 0.1))} onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button aria-label="Schrift verkleinern">A−</button
> >
<button <button type="button" onclick={() => changePromptSize(0.1)} aria-label="Schrift vergrößern"
type="button" >A+</button
onclick={() => (promptSize = Math.min(2.1, promptSize + 0.1))}
aria-label="Schrift vergrößern">A+</button
> >
</div> </div>
<a href={resolve('/sendungen/[id]', { id: data.show.id })}>Planung schließen</a> <button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}
>{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}</button
>
<button type="button" class="text-button" aria-pressed={!showRail} onclick={toggleRail}
>{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}</button
>
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}>Zur Planung</a>
</div> </div>
</header> </header>
<PreviewNotice area="Live-Modus" /> {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
<div class="live-grid"> <section class="live-toolbar" aria-label="Live-Sitzung">
{#if data.current}<section class="live-current" style={`--prompt-size: ${promptSize}rem`}> <form method="POST" action="?/start" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
</button>
</form>
<form method="POST" action="?/createOutput" use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Präsentationsfenster öffnen</button>
</form>
<div class="live-session-summary">
<strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span>
</div>
</section>
<div class:without-rail={!showRail} class="live-grid">
{#if data.live.current}<main
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
>
<div class="live-kicker"> <div class="live-kicker">
<span>Aktuell · {data.current.section}</span><strong <span>Aktuell · {data.live.current.sectionName}</span>
>{formatClock(data.current.durationSeconds)}</strong {#if showTimes}<strong
> >{formatClock(currentActualSeconds)} / {formatClock(
</div> data.live.current.durationSeconds
<h2>{data.current.title}</h2> )}</strong
<div class="prompter-copy">{data.current.intro}</div> >{/if}
<div class="moderation-card">
<span>Moderationsstichpunkte</span>
<p>{data.current.moderation}</p>
</div> </div>
<h2 id="current-title">{data.live.current.title}</h2>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
{#if data.live.current.moderationHtml}<section class="moderation-card">
<span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
</section>{/if}
<details class="research-details"> <details class="research-details">
<summary>Recherche öffnen</summary> <summary>Manuelle Recherche öffnen</summary>
<p> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim <div class="live-markdown">{@html data.live.current.researchHtml}</div>
Öffnen die Leseposition.
</p>
</details> </details>
<div class="transition-card"> {#if data.live.current.transitionHtml}<section class="transition-card">
<span>Überleitung</span> <span>Überleitung</span>
<p>{data.current.transition}</p> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
</div> <div class="live-markdown">{@html data.live.current.transitionHtml}</div>
</section>{:else}<section class="live-current live-empty"> </section>{/if}
<p class="eyebrow">Noch kein Piece eingeplant</p>
<h2>Diese Sendung hat noch keinen moderierbaren Inhalt.</h2>
<p>In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.</p>
</section>{/if}
<aside class="live-rail"> <div class="live-current-actions">
<div class="next-card"> <form method="POST" action="?/back" use:enhance={enhanceCommand}>
<span>Als Nächstes</span> <input type="hidden" name="showId" value={data.live.show.id} />
{#if data.next} <input type="hidden" name="revision" value={data.live.liveRevision} />
<h3>{data.next.title}</h3> <input type="hidden" name="commandId" value={data.commandId} />
<p>{data.next.section} · {formatClock(data.next.durationSeconds)}</p> <button class="secondary-button" type="submit">Zurück</button>
{:else} </form>
<h3>Sendungsende</h3> <form method="POST" action="?/present" use:enhance={enhanceCommand}>
<p>Kein weiteres Stück eingeplant.</p> <input type="hidden" name="showId" value={data.live.show.id} />
{/if} <input type="hidden" name="usageId" value={data.live.current.id} />
</div> <input type="hidden" name="revision" value={data.live.liveRevision} />
<div class="live-progress"> <input type="hidden" name="commandId" value={data.commandId} />
<header><span>Ablauf</span><strong>2 / 4</strong></header> <button class="secondary-button" type="submit">
<div class="progress-track"><span></span></div> {data.live.presentationUsageId === data.live.current.id
{#each data.show.sections as section, index (section.id)} ? 'Erneut präsentieren'
<div class:active={index === 1} class="rail-section"> : 'Präsentieren'}
<span>{String(index + 1).padStart(2, '0')}</span> </button>
<div> </form>
<strong>{section.name}</strong><small>{formatClock(section.durationSeconds)}</small> <form method="POST" action="?/skip" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Überspringen</button>
</form>
<form method="POST" action="?/complete" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">Erledigen und weiter</button>
</form>
</div>
</main>{:else}<main class="live-current live-empty">
<p class="eyebrow">Kein aktives Piece</p>
<h2>{data.live.totalCount ? 'Live-Stand bereit' : 'Noch kein Piece eingeplant'}</h2>
<p>
{data.live.totalCount
? 'Sendung starten oder einen abgeschlossenen Schritt zurückgehen.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
</p>
{#if data.live.completedCount}<form
method="POST"
action="?/back"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zum letzten Piece zurück</button>
</form>{/if}
</main>{/if}
{#if showRail}<aside class="live-rail">
<section class="next-card">
<span>Als Nächstes</span>
{#if data.live.next}
<h3>{data.live.next.title}</h3>
<p>
{data.live.next.sectionName}{#if showTimes}
· {formatClock(data.live.next.durationSeconds)}{/if}
</p>
{:else}
<h3>Sendungsende</h3>
<p>Kein weiteres offenes Piece eingeplant.</p>
{/if}
</section>
<section class="live-progress">
<header>
<span>Ablauf</span><strong>{data.live.completedCount} / {data.live.totalCount}</strong>
</header>
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
{#each data.live.sections as section, sectionIndex (section.id)}
<div class:active={section.id === data.live.current?.sectionId} class="rail-section">
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong>
{#if showTimes}<small>{formatDuration(section.durationSeconds)}</small>{/if}
</div>
</div> </div>
</div> {#each section.usages as usage, usageIndex (usage.id)}
{/each} <div class:current={usage.id === data.live.current?.id} class="live-run-item">
</div> <div>
<div class="readonly-actions" aria-label="Spätere Live-Aktionen"> <strong>{usage.title}</strong><small>{usage.status}</small>
<span>Zurück</span><strong>In Etappe 5 steuerbar</strong><span>Erledigen</span> </div>
</div> <div class="live-run-actions">
</aside> {#if usage.status === 'open'}
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex - 1} />
<button
type="submit"
disabled={usageIndex === 0 ||
section.usages[usageIndex - 1]?.status !== 'open'}
aria-label={`${usage.title} nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex + 1} />
<button
type="submit"
disabled={usageIndex === section.usages.length - 1 ||
section.usages[usageIndex + 1]?.status !== 'open'}
aria-label={`${usage.title} nach unten`}>↓</button
>
</form>
{/if}
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button type="submit" aria-label={`${usage.title} präsentieren`}>↗</button>
</form>
</div>
</div>
{/each}
{/each}
</section>
<section class="live-reserve">
<header><span>Reserve</span><strong>{data.live.reserve.length}</strong></header>
{#each data.live.reserve as usage, reserveIndex (usage.id)}
<div class="live-reserve-item">
<div>
<strong>{usage.title}</strong>{#if showTimes}<small
>{formatClock(usage.durationSeconds)}</small
>{/if}
</div>
<div class="live-run-actions">
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex - 1} />
<button
type="submit"
disabled={reserveIndex === 0}
aria-label={`${usage.title} in Reserve nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex + 1} />
<button
type="submit"
disabled={reserveIndex === data.live.reserve.length - 1}
aria-label={`${usage.title} in Reserve nach unten`}>↓</button
>
</form>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action="?/moveUsage"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input
type="hidden"
name="sectionId"
value={data.live.current?.sectionId ?? data.live.sections[0]?.id}
/>
<input
type="hidden"
name="position"
value={(data.live.current?.position ?? -1) + 1}
/>
<button class="text-button" type="submit">Als Nächstes</button>
</form>{/if}
</div>
</div>
{/each}
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
</section>
</aside>{/if}
</div> </div>
</div> </div>
+1 -1
View File
@@ -175,7 +175,7 @@
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span> <strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div> </div>
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)} <a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
>Live-Vorschau <span>↗</span></a >Live-Steuerung <span>↗</span></a
> >
</div> </div>
</header> </header>
+93 -7
View File
@@ -486,22 +486,108 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
expect(boxes[1]?.y).toBe(boxes[2]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y);
}); });
test('live preview retains its existing readable controls', async ({ page }) => { test('live control persists state and exposes only explicit presentation data', async ({
page,
browser
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live'); await page.goto('/fundus?q=kleine+Konsole');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'live-konsole.png',
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.goto('/live?show=show-october');
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
await page.getByRole('button', { name: 'Sendung starten' }).click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
).not.toBeVisible();
await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
const before = await page const before = await page
.locator('.live-current') .locator('.live-current')
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
const after = await page await page.reload();
const restored = await page
.locator('.live-current') .locator('.live-current')
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
expect(after).not.toBe(before); expect(restored).not.toBe(before);
await page.getByText('Recherche öffnen').click(); await page.getByText('Manuelle Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expect(page.locator('.research-details')).toHaveAttribute('open', '');
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click()
]);
await popup.waitForURL(/\/heinzlive\/[A-Za-z0-9_-]+$/);
const outputUrl = popup.url();
const anonymous = await browser.newContext();
const output = await anonymous.newPage();
await output.goto(outputUrl);
await expect(
output.getByRole('heading', { name: 'Bereit für den nächsten Inhalt' })
).toBeVisible();
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible({
timeout: 10_000
});
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible();
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
const outputHtml = await output.content();
expect(outputHtml).not.toContain('Herstellerangaben');
expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
expect(outputHtml).not.toContain(adminEmail);
expect(outputHtml).not.toContain('live-konsole.png');
const outputState = await anonymous.request.get(`${outputUrl}/state`);
expect(outputState.status()).toBe(200);
const outputJson = await outputState.text();
expect(outputJson).toContain('Weniger Leistung');
expect(outputJson).not.toContain('Herstellerangaben');
expect(outputJson).not.toContain('Moderationsstichpunkte');
expect(outputJson).not.toContain('live-konsole.png');
await page.getByRole('button', { name: 'Als Nächstes' }).click();
await expect(page.locator('.next-card')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Erledigen und weiter' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: 'Zurück' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart');
await output.route('**/state', (route) => route.abort());
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });
await output.unroute('**/state');
await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 });
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('live-controller-mobile.png'),
fullPage: true
});
await page.setViewportSize({ width: 1280, height: 900 });
await page.evaluate(() => {
window.open = () => null;
});
await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click();
await expect(
page.getByText('Popup blockiert. Bitte Popups erlauben und erneut öffnen.')
).toBeVisible();
await anonymous.close();
await popup.close();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });