18 Commits
Author SHA1 Message Date
hnzio ec596d442e Release 0.5.12 2026-10-24 02:08:32 +02:00
hnzio bd9ffe6faa Build compact live control view 2026-10-24 01:54:41 +02:00
hnzio 3389ba17f4 Fix topic icon picker placement 2026-10-23 23:55:34 +02:00
hnzio c05cea3a6d Release 0.5.11 2026-10-23 23:43:05 +02:00
hnzio 4fe61971e4 Release 0.5.10 2026-10-23 21:33:15 +02:00
hnzio c3033479b5 Release 0.5.9 2026-10-23 13:16:32 +02:00
hnzio 2851c7d936 Release 0.5.8 2026-10-23 07:47:27 +02:00
hnzio 22ae3a68d6 Release 0.5.7 2026-10-23 00:14:43 +02:00
hnzio dfa236390d Release 0.5.6 2026-10-22 20:54:46 +02:00
hnzio b2467b87f5 Release 0.5.5 2026-10-22 20:41:34 +02:00
hnzio 4fed048843 Release 0.5.4 2026-10-22 19:17:14 +02:00
hnzio f466df2c5c Release 0.5.3 2026-10-22 14:18:57 +02:00
hnzio 25da870601 Release 0.5.2 2026-10-22 13:51:30 +02:00
hnzio fb24d171ae Release 0.5.1 2026-10-22 07:56:25 +02:00
hnzio 5a9d7735d4 Release 0.5.0 2026-10-22 00:28:49 +02:00
hnzio 4042fe6a64 Release 0.4.0 2026-10-21 23:52:05 +02:00
hnzio 7590be66a5 Release 0.3.0 2026-10-21 21:05:57 +02:00
hnzio 08e8875cb5 Release 0.2.0 2026-10-21 17:25:28 +02:00
102 changed files with 60263 additions and 722 deletions

No files matched your search

+17
View File
@@ -1,3 +1,20 @@
# Relative paths are resolved from the project directory.
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads
SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox
BODY_SIZE_LIMIT=6M
# Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN=
SENDEWERK_APP_ORIGIN=http://localhost:5173
# "local" writes messages to the outbox and never contacts a mail server.
SENDEWERK_MAIL_TRANSPORT=local
SENDEWERK_MAIL_FROM=Sendewerk <sendewerk@localhost>
# Required only when SENDEWERK_MAIL_TRANSPORT=smtp is explicitly selected.
SENDEWERK_SMTP_HOST=
SENDEWERK_SMTP_PORT=587
SENDEWERK_SMTP_SECURE=false
SENDEWERK_SMTP_USER=
SENDEWERK_SMTP_PASSWORD=
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.1.1",
"version": "0.5.12",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+135 -8
View File
@@ -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
ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten
und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte
Live-Steuerung 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
@@ -20,6 +20,9 @@ nicht erforderlich.
```sh
npm ci
cp .env.example .env
SETUP_TOKEN="$(openssl rand -hex 32)"
sed -i "s/^SENDEWERK_SETUP_TOKEN=.*/SENDEWERK_SETUP_TOKEN=$SETUP_TOKEN/" .env
printf 'Betreiber-Setup-Token: %s\n' "$SETUP_TOKEN"
npm run setup
npm run dev
```
@@ -29,18 +32,125 @@ erstellt die konfigurierten Verzeichnisse, führt ausstehende Migrationen aus
und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft
ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten.
Beim ersten Aufruf verlangt `/setup` den ausgegebenen Wert aus
`SENDEWERK_SETUP_TOKEN` und legt genau einen Barkeeper an. Der Befehl schreibt
den Wert in die nicht versionierte `.env`, damit auch spätere lokale Starts
dieselbe Konfiguration verwenden. Nach erfolgreicher Einrichtung ist `/setup`
dauerhaft geschlossen und der Token kann aus `.env` entfernt werden. Der Seed
enthält bewusst kein Konto und kein Standardpasswort.
## Konfiguration
| Variable | Vorgabe | Bedeutung |
| ---------------------- | ------------------------ | ----------------------------------- |
| --------------------------- | ------------------------ | ---------------------------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
| `BODY_SIZE_LIMIT` | `6M` | Requestlimit für private Bild-Uploads bis 5 MB |
| `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen |
| `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit |
| `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` |
| `SENDEWERK_MAIL_FROM` | lokaler Absender | Absender; bei SMTP verpflichtend |
| `SENDEWERK_SMTP_HOST` | keine | SMTP-Server, nur im SMTP-Modus |
| `SENDEWERK_SMTP_PORT` | `587` | SMTP-Port |
| `SENDEWERK_SMTP_SECURE` | `false` | `true` für implizites TLS, üblicherweise Port 465 |
| `SENDEWERK_SMTP_USER` | keine | optionaler SMTP-Benutzer |
| `SENDEWERK_SMTP_PASSWORD` | keine | optionales SMTP-Passwort |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
| `PORT` | `3000` | Port des Produktionsservers |
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`.
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
findet nur statt, wenn `SENDEWERK_MAIL_TRANSPORT=smtp` und die SMTP-Werte
explizit gesetzt sind.
## Inhalte und private Bilder
Neue Gedanken oder Links lassen sich im Eingang schnell erfassen. Im Fundus
stehen leere Pieces sowie Vorlagen für Gespräch, Reaction/Fundstück und
Interaktion bereit. Der Editor speichert Änderungen nach kurzer Pause
automatisch; ein sichtbarer Status meldet Erfolg, Fehler oder eine parallele
Änderung. In diesem Fall muss die Seite neu geladen werden, bevor erneut
gespeichert wird.
Bereits gesendete Pieces und Pieces mit einer Einplanung in einer noch nicht
abgeschlossenen Sendung sind standardmäßig ausgeblendet. Als gesendet gilt nur
eine erledigte Verwendung in einer abgeschlossenen Sendung; übersprungene oder
reservierte Inhalte gelten nicht als gesendet. Über **Auch verwendete Inhalte
zeigen** und **Auch eingeplante Inhalte zeigen** lassen sich beide Gruppen
unabhängig einblenden. Dann erscheinen auch Datum, Sendung und die aufklappbare
Verwendungshistorie.
Markdown ist in Einleitung, Recherche, Moderation, Überleitung und Präsentation
möglich. Die Ausgabe wird serverseitig auf eine begrenzte Auswahl sicherer
Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müssen
als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten
Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar.
Für die Präsentation kann ein Piece alternativ genau eine seiner vorhandenen
Quellen als Linkkarte oder Video verwenden. Linktitel, Anreißer und Bildadresse
lassen sich manuell korrigieren. **Vorschau laden** ruft Metadaten ausdrücklich
neu ab; manuell geänderte Felder werden dabei nicht überschrieben. Die
**Präsentation testen**-Ansicht öffnet den aktuellen Piece-Inhalt isoliert und
verändert weder eine Sendung noch deren laufende Ausgabe.
## Sendungen planen
Unter **Sendungen** lässt sich ein neuer Stream leer, mit dem Standardablauf
oder als Spieleabend anlegen. Abschnitte sind frei benennbar und können eine
explizite Dauer erhalten; andernfalls ergibt sich ihre Dauer aus den
eingeplanten Pieces. Die Reservebank zählt nie zur Planzeit.
Der sendefertige Fundus steht neben dem Ablauf. Pieces können mehrfach
eingeplant und je Verwendung mit eigenem Titel, eigener Dauer und eigenen
Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Das
Stift-Symbol öffnet dafür einen auch mobil scrollbaren Editor-Dialog. **Aus
Fundus aktualisieren** übernimmt auf Wunsch Titel, Dauer und Redaktionstexte des
aktuellen Originals; eigene Notizen, Live-Status und gemessene Zeiten bleiben
erhalten. Abschnitte und Verwendungen lassen sich per Drag-and-drop sowie
gleichwertig mit sichtbaren Schaltflächen und Auswahlfeldern verschieben.
Twitch-Titel und Ankündigung sind kopierbar. Gleichzeitige Änderungen werden
über die Revision der gesamten Sendung erkannt und nicht still überschrieben.
Die Quellspalte blendet gesendete und bereits in derselben Sendung eingeplante
Originale zunächst aus; beide Gruppen lassen sich unabhängig wieder anzeigen.
Eine konkrete Einplanung kann direkt hinter sich dupliziert werden. Dabei
bleiben ihre angepassten Texte und ihre Dauer erhalten, während Live-Status und
gemessene Zeiten neu beginnen. **Piece duplizieren** im Fundus erzeugt dagegen
weiterhin einen eigenständigen Fundusinhalt.
## Live moderieren und präsentieren
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 den bei der Einplanung gespeicherten Text-, Link-
oder Video-Snapshot. YouTube- und TikTok-Adressen werden auf validierte IDs
reduziert und ohne Autoplay in offiziellen Player-Iframes dargestellt. Andere
oder unvollständige Videoquellen erscheinen als Linkkarte. 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.
Metadaten und externe Vorschaubilder werden ausschließlich serverseitig mit
DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale
Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
werden nicht übernommen.
## Befehle
| Befehl | Zweck |
@@ -48,7 +158,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
| `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung |
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
| `npm run db:seed` | Reproduzierbare Beispieldaten laden |
| `npm run dev` | Entwicklungsserver starten |
| `npm run dev` | Migrationen ausführen und Entwicklungsserver starten |
| `npm run check` | Svelte- und TypeScript-Prüfung |
| `npm run lint` | Formatierung und ESLint prüfen |
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
@@ -61,6 +171,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
```sh
npm ci
npm run setup
export SENDEWERK_APP_ORIGIN=http://localhost:3000
npm run build
HOST=127.0.0.1 PORT=3000 npm start
```
@@ -68,6 +179,12 @@ HOST=127.0.0.1 PORT=3000 npm start
Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den
Datenbankzugriff.
Für lokales HTTP wird das Sitzungscookie gezielt ohne `Secure` gesetzt, bleibt
aber `HttpOnly` und `SameSite=Lax`. Bei einer `https:`-Origin ist `Secure`
automatisch aktiv. Die Build-Origin muss beim lokalen Produktionsbuild mit der
später verwendeten Browser-Origin übereinstimmen; SvelteKits Origin-Prüfung
bleibt aktiv.
Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert:
```sh
@@ -82,9 +199,17 @@ Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und
```sh
docker build -t sendewerk:local .
docker run --rm -p 3000:3000 -v sendewerk-data:/app/data sendewerk:local
docker run --rm -p 3000:3000 \
-e SENDEWERK_APP_ORIGIN=http://localhost:3000 \
-e SENDEWERK_SETUP_TOKEN="$(openssl rand -hex 32)" \
-v sendewerk-data:/app/data sendewerk:local
```
Auf Cloudron muss das Betreiber-Setup-Token vor dem ersten Browseraufruf als
Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden.
@@ -101,5 +226,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter
Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und
Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
installierten Baum.
+11
View File
@@ -0,0 +1,11 @@
# Third-party notices
## Font Awesome Pro Plus 7.0.0
The icon metadata in `src/lib/icons/classic-regular.ts` and the SVG sprite in
`static/icons/classic-regular.svg` are derived from Font Awesome Pro Plus 7.0.0
Classic Regular. Font Awesome Pro is commercial software and remains subject to
the Font Awesome Pro commercial license:
https://fontawesome.com/license
The Sendewerk MIT license does not apply to that icon data.
+414
View File
@@ -39,3 +39,417 @@
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
Python-Modul `playwright` nicht installiert war. Die projektgebundene
TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich.
## Etappe 2
| Kriterium | Nachweis | Stand |
| ------------------------------- | ----------------------------------------------------------------------------------- | --------------------- |
| Erstadmin danach geschlossen | Browser-Setup sowie wiederholter Service-Aufruf geprüft; Seed enthält kein Konto | erfüllt am 21.10.2026 |
| Login, Logout und lokales HTTP | Browserablauf; `HttpOnly`, `SameSite=Lax`, ohne `Secure` bei expliziter HTTP-Origin | erfüllt am 21.10.2026 |
| Einladung und Reset | lokale Outbox bis Kontoaktivierung und neuem Login im Browser durchlaufen | erfüllt am 21.10.2026 |
| Einmalige und abgelaufene Links | Verhaltenstests für Wiederverwendung und Ablauf von Einladung und Reset | erfüllt am 21.10.2026 |
| Direkte Admin-Aktion verweigert | Thekencrew-POST auf Einladungsaction liefert 403 | erfüllt am 21.10.2026 |
| Sperrung entzieht Zugriff | aktive Thekencrew-Sitzung wird gelöscht und beim nächsten Request abgewiesen | erfüllt am 21.10.2026 |
| Keine echte Mail ungefragt | Browser- und Verhaltenstests nutzen ausschließlich temporäre lokale Outbox | erfüllt am 21.10.2026 |
| Echter SMTP-Versand | nicht ausgeführt, keine Zugangsdaten bereitgestellt | offen |
## Ausgeführte Prüfungen Etappe 2
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 9 Auth-, Migrations-, Mail- und Dauer-Verhaltenstests erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 9 von 9 Browsertests erfolgreich; Erstadmin,
Login/Logout, fremder Origin, Einladung, direkte Rollenprüfung, Sperrung,
Reset sowie bestehende responsive Arbeitsansichten geprüft
- Lokale Outbox in isolierten temporären Verzeichnissen geprüft
- Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck
und Migration vor Start erfolgreich
- `bash -n start.sh`: erfolgreich
## Etappe 3
| Kriterium | Nachweis | Stand |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------- |
| Anlegen, Bearbeiten, Suchen, Filtern und Reload | Browserrundlauf mit Vorlage, Autosave, Quellen, Status, Suche und Neuladen | erfüllt am 21.10.2026 |
| Taxonomien unabhängig | Format einzeln sowie Themen und Abschnittskategorien mehrfach gespeichert und gefiltert | erfüllt am 21.10.2026 |
| Sicheres Markdown | Verhaltenstest und Browservorschau entfernen Skript/JavaScript, Formatierung bleibt erhalten | erfüllt am 21.10.2026 |
| Private Bilder | Signatur-/Größenprüfung; anonymer Direktrequest erhält keinen Dateiinhalt | erfüllt am 21.10.2026 |
| Sichtbare Speicherfehler | Actionfehler und Autosave-Fehlerstatus bleiben am Formular sichtbar | erfüllt am 21.10.2026 |
| Konflikterkennung | zwei Browserseiten; veraltete Revision erhält Konflikt und überschreibt die neue Fassung nicht | erfüllt am 21.10.2026 |
| Archiv und Wiederherstellung | Browserrundlauf blendet Piece aus und stellt den vorherigen Status wieder her | erfüllt am 21.10.2026 |
| Duplikat | Verhaltenstest und Browser prüfen unabhängige Kopie samt Zuordnungen und Bild | erfüllt am 21.10.2026 |
## Ausgeführte Prüfungen Etappe 3
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 13 Tests in 3 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 10 von 10 Browsertests erfolgreich
- Browserprüfung bei 390, 768, 960, 1280 und 1440 Pixeln in beiden Themes ohne
ungewollten horizontalen Überlauf
- Mobile Darkmode-Aufnahme des Piece-Editors visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Etappe 4
| Kriterium | Nachweis | Stand |
| ----------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------- |
| Spieleabend und mehrere JC-/Spieleblöcke | persistierte Spieleabend-Vorlage, Seed-Sendung und Browserrundlauf mit Standardablauf | erfüllt am 21.10.2026 |
| Reihenfolge und Texte bleiben nach Reload | Verhaltenstests sowie Browsertest für Tastatur- und Drag-and-drop-Sortierung | erfüllt am 21.10.2026 |
| Original bleibt unverändert | Service- und Browsertest planen dasselbe Piece mehrfach mit eigenen Titeln, Notizen, Dauern | erfüllt am 21.10.2026 |
| Keine doppelte Dauer; Reserve zählt nicht | explizite und summierte Abschnitte sowie Reserve in Verhaltenstest und Browser geprüft | erfüllt am 21.10.2026 |
| Entfernen betrifft nur Einplanung | Verhaltenstest löscht Verwendung und Abschnittskopie, nicht das Original | erfüllt am 21.10.2026 |
| Sendungsänderung verändert keine Vorlage | tiefe Vorlagenkopie vor und nach Abschnittsänderung verglichen | erfüllt am 21.10.2026 |
| Smartphone-Planungsablauf | Abschnittsanlage, Pane-Umschaltung und Überlauf bei 390 Pixeln geprüft | erfüllt am 21.10.2026 |
| Gleichwertige Bedienwege | Drag-and-drop, Enter auf Verschiebeschaltfläche sowie klick-/touchbare Zielauswahl geprüft | erfüllt am 21.10.2026 |
| Lokale Planungsaktualisierung | Quelle und Reserve per Drag-and-drop ohne Dokumentreload; lokaler Speicherzustand geprüft | erfüllt am 21.10.2026 |
| Sichtbare Einfügeposition | Abschnitt, Fundusquelle und Reserve zeigen vor dem Drop Vorschau und transparentes Original | erfüllt am 21.10.2026 |
| Piece-Dauer in Minuten | Fundus- und Verwendungseditor rechnen Minuten serverseitig in ganze Sekunden um | erfüllt am 21.10.2026 |
| Gleichzeitige Änderungen | veraltete showweite Revision rollt die gesamte Mutation zurück | erfüllt am 21.10.2026 |
## Ausgeführte Prüfungen Etappe 4
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 18 Tests in 4 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich
- Browserprüfung bei 390, 768, 960, 1280 und 1440 Pixeln in beiden Themes ohne
ungewollten horizontalen Überlauf
- Mobile Aufnahme des vollständigen Sendungsbaukastens visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Etappe 5
| Kriterium | Nachweis | Stand |
| ------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------- |
| Lesbare Moderation | Prompter-Schrift, Einleitung und Überleitung im Browser; Recherche initial geschlossen | erfüllt am 22.10.2026 |
| Wiederherstellung | Live-Stand, Laufintervalle, Schriftgröße und Leseposition nach Neuladen geprüft | erfüllt am 22.10.2026 |
| Keine doppelte Zeiterfassung | Verhaltenstest wiederholt dieselbe Befehls-ID und findet genau ein Zeitintervall | erfüllt am 22.10.2026 |
| Explizite Präsentation | Zweitfenster bleibt nach Start leer und ändert sich erst durch „Präsentieren“ | erfüllt am 22.10.2026 |
| Getrennte sichere Ausgabe | anonymer Browserkontext prüft Text, Bild, HTML, JSON und fremde Medien ohne private Daten | erfüllt am 22.10.2026 |
| Reserve und Reihenfolge | Reserve als nächstes Piece eingefügt; offene Reihenfolge und serverseitige Live-Grenze geprüft | erfüllt am 22.10.2026 |
| Popup- und Verbindungsfehler sichtbar | blockiertes Popup sowie unterbrochenes und wiederhergestelltes State-Polling im Browser geprüft | erfüllt am 22.10.2026 |
| Persistente Linkvorschau | Quellwahl, manuelle Felder, Reload, fehlendes Bild und isolierte Testansicht im Browser geprüft | erfüllt am 22.10.2026 |
| Sichere Metadatenabrufe | Tests sperren private IPv4-/IPv6-Ziele; Redirect-, Größen- und Zeitlimits sind implementiert | erfüllt am 22.10.2026 |
| Validierte Videoplayer | YouTube-URL-Varianten, TikTok-ID, Zeitmarke und Playerwechsel automatisiert geprüft | erfüllt am 22.10.2026 |
| Präsentationssnapshot | Einplanung, Duplikation und explizite Aktualisierung aus dem Original im Servicetest geprüft | erfüllt am 22.10.2026 |
| Reale OBS-Browserquelle | lokale tokenisierte Browserausgabe vorhanden; kein OBS auf der Prüfmaschine | offen |
| Reale Anbieterwiedergabe | automatisierte Tests fangen YouTube-/TikTok-Requests lokal ab | offen |
## Ausgeführte Prüfungen Etappe 5
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 14 von 14 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
- YouTube-/TikTok-Iframes, Playerwechsel, mobile Hochformatausgabe,
Linkkartenvorschau ohne Bild, isolierte Testansicht und private Datenfilterung
mit lokal abgefangenen Anbieterrequests geprüft
- SSRF-Blockade einer privaten Zieladresse über die sichtbare Editorfehlermeldung
geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Gezielte Fundus- und Einplanungsanpassung
| Kriterium | Nachweis | Stand |
| ------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------- |
| Gesendet korrekt abgeleitet | Servicetest schließt offene, übersprungene, Reserve- und nicht abgeschlossene Nutzung aus | erfüllt am 22.10.2026 |
| Historie und Fundusfilter | Browser prüft letztes Datum, Sendungslink, weitere Verwendungen und Sofortfilter | erfüllt am 22.10.2026 |
| Unabhängige Quellfilter | Browser kombiniert verwendet/eingeplant getrennt und erhält sie über Suchfilter | erfüllt am 22.10.2026 |
| Reserve und Mehrfacheinplanung | Reserve zählt als eingeplant; bewusstes Wiedereinblenden erlaubt weitere Einplanung | erfüllt am 22.10.2026 |
| Rückkehr nach Entfernen | Browser entfernt die letzte Reserveeinplanung und bietet das Original sofort wieder an | erfüllt am 22.10.2026 |
| Angepasste Einplanung dupliziert | Service und Browser prüfen Position, Texte, Notizen, Dauer und Reload | erfüllt am 22.10.2026 |
| Live-Zustand des Duplikats zurückgesetzt | Servicetest prüft `open`, neue ID und fehlende Zeitintervalle | erfüllt am 22.10.2026 |
| Original unverändert | Piece-Anzahl und Original-ID vor und nach Einplanungsduplikation verglichen | erfüllt am 22.10.2026 |
| Speicherfehler nicht als Erfolg dargestellt | veraltete Revision rollt Duplikation vollständig zurück | erfüllt am 22.10.2026 |
| Globale aktive Einplanung ausgeblendet | Browser prüft den unabhängigen Filter über mehrere nicht abgeschlossene Sendungen | erfüllt am 22.10.2026 |
| Snapshot ausdrücklich aktualisiert | Service prüft neue Originaltexte und erhaltene Notizen/Intervalle; Browser löst Aktion aus | erfüllt am 22.10.2026 |
| Fehlendes Original atomar behandelt | Servicetest prüft Fehler und unveränderte Sendungsrevision | erfüllt am 22.10.2026 |
| Verwendungsdialog responsiv | Browser prüft Desktop/Mobil, Reserve, erreichbare Aktionen und sichtbare Konfliktfehler | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Fundus und Einplanung
- `npm test`: 22 Tests in 5 Dateien erfolgreich
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- gezielter Playwright-Lauf: 4 von 4 Browsertests erfolgreich
- `npm run test:e2e`: 12 von 12 Browsertests erfolgreich
- Desktopansichten von Fundushistorie, kombinierten Sendungsfiltern und
Verwendungsdialog sowie mobile Quellspalte und mobiler Dialog bei 390 × 844
Pixeln visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Redaktionelles Piece-Arbeitsblatt
| Kriterium | Nachweis | Stand |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Piece-Arbeitsblatt
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
1 von 1 Browsertest erfolgreich
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Oberflächenrelease 0.5.4
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------- | --------------------- |
| Klare Hierarchie in beiden Themes | Eingang, Fundus, Editor, Sendungsplan und Live-Steuerung in Light/Dark visuell kontrolliert | erfüllt am 22.10.2026 |
| Exakte heinzlive-Darkmode-Farben | berechnete Theme-Tokens und Screenshots der dunklen Arbeitsansichten geprüft | erfüllt am 22.10.2026 |
| Responsive Darstellung ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln auf allen gezielt geprüften Ansichten | erfüllt am 22.10.2026 |
| Sendungsplan-Aktionen bleiben erreichbar | drei sichtbare Aktionen einer Planzeile liegen waagerecht auf gleicher Höhe; Mobilbild geprüft | erfüllt am 22.10.2026 |
| Fokus und reduzierte Bewegung | sichtbarer Cyan-Fokusring und bestehende `prefers-reduced-motion`-Regeln im Stylesheet geprüft | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenrelease 0.5.4
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Chromium-Lauf für Eingang, Fundus, Piece-Editor, Sendungsplan und
Live-Steuerung bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne
Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop- und Mobilaufnahmen aller genannten Ansichten visuell kontrolliert
- vollständige Unit- und E2E-Matrix nicht erneut ausgeführt, weil dieser Release
ausschließlich Darstellung und redaktionelle Kurztexte verändert
- `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.5
| Kriterium | Nachweis | Stand |
| ------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------- |
| Nachtblau und Glaswirkung erkennbar | berechnete Transparenz/Unschärfe und Desktop-/Mobilaufnahmen in Darkmode geprüft | erfüllt am 22.10.2026 |
| Schreibfelder im Ruhezustand sichtbar | deckende Feldfarbe, feine Kontur, Innenabstand und Textcursor im Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Goldener Tastaturfokus | berechnete Fokusfarbe und Fokus-Schatten am Titelfeld geprüft; Cyanrahmen nicht mehr aktiv | erfüllt am 22.10.2026 |
| Inhaltsgerechte Feldhöhen | kurze Einleitung ist niedriger als ausführliche Moderationsfläche; beide bleiben skalierbar | erfüllt am 22.10.2026 |
| Lightmode und Smartphone erhalten | fünf Arbeitsansichten bei 1440 × 1000 und 390 × 844 Pixeln ohne Seitenüberlauf geprüft | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.5
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Chromium-Lauf in Light/Dark und Desktop/Mobil ohne Konsolenfehler
oder horizontalen Seitenüberlauf; Glas-, Feld- und Fokuswerte zusätzlich
programmatisch geprüft
- vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt
- `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.6
| Kriterium | Nachweis | Stand |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Gold-/Violettlicht und Glasflächen sichtbar | Fundus und Editor in Light/Dark auf Desktop und Mobil visuell sowie über berechnete Werte geprüft | erfüllt am 22.10.2026 |
| Schreibflächen ruhig und eindeutig abgegrenzt | deckende Feldfarbe, Kontur und goldener Fokus im geöffneten Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Neuanlage und Suche unmittelbar erreichbar | sichtbare Fundusaktionen sowie geöffnete Vorlagenauswahl auf Desktop und Mobil geprüft | erfüllt am 22.10.2026 |
| Zusatzfilter und Zeilenaktionen zugänglich | native Aufklappelemente für Filter und Aktionen per Browserinteraktion geprüft | erfüllt am 22.10.2026 |
| Funduszeilen per Tastatur zu öffnen | erster Titeleintrag fokussiert, mit Eingabetaste geöffnet und Editoraufruf geprüft | erfüllt am 22.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.6
- `npm run check`: 0 Fehler und 0 Warnungen
- gezielter Chromium-Lauf für Fundus und geöffneten Piece-Editor bei
1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne Konsolenfehler oder
horizontalen Seitenüberlauf
- Neuanlage, Zusatzfilter, Aktionsmenü, Tastaturöffnung, Glaswerte und goldener
Fokus automatisiert kontrolliert; alle erzeugten Desktop-/Mobilaufnahmen
visuell geprüft
- Produktionsbuild sowie vollständige Unit- und E2E-Matrix gemäß Auftrag nicht
ausgeführt
- `git diff --check`: erfolgreich
## Materialkorrektur 0.5.7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| Hintergrundlicht im Arbeitsbereich sichtbar | Fundus und Sendungsplanung ohne Popup in Dark/Light auf Desktop und Mobil visuell geprüft | erfüllt am 23.10.2026 |
| Popup erhält die Lichtstimmung | Editor mit leichtem `46%`-Backdrop und `6px` Blur gegen Ansicht ohne Popup verglichen | erfüllt am 23.10.2026 |
| Verschachtelte Ablaufkarten werden dunkler | berechnete RGBA-Werte von Außenglas, Abschnitt, Piece und Aktionsgruppe kontrolliert | erfüllt am 23.10.2026 |
| Graue Sonderflächen entfernt | aktive Tabs, private Recherche, Quellen-/Materialflächen und Aktionsgruppen visuell geprüft | erfüllt am 23.10.2026 |
| Schreibfelder bleiben ruhig und klar | deckende Feldfüllung, feine Kontur und unveränderter Goldfokus in beiden Editorbereichen geprüft | erfüllt am 23.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 23.10.2026 |
## Ausgeführte Prüfungen Materialkorrektur 0.5.7
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- berechnete Styles vor und nach der Korrektur für App-Hintergrund, Navigation,
Ablauf-Panel, Abschnitte, Pieces, Aktionsgruppen, Dialog, Backdrop, Tabs,
Rechercheflächen und Schreibfelder kontrolliert
- gezielter Chromium-Lauf für Sendungsplanung sowie Piece-Editor mit
„Sendungstext“ und „Recherche & Quellen“ bei 1440 × 1000 und 390 × 844 Pixeln
in Light/Dark ohne Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop-/Mobilaufnahmen mit und ohne Popup visuell verglichen
- vollständige Funktionstests gemäß Auftrag nicht ausgeführt
- `git diff --check`: erfolgreich
## Redaktionelle Arbeitsabläufe 0.5.8
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------- |
| Persistente unabhängige Piece-Vorlagen | Servicetests prüfen Snapshot, Revision, Neuanlage, Wiederverwendung und Löschen | erfüllt am 23.10.2026 |
| Atomare idempotente Direktanlage | Verhaltenstests prüfen Wiederholung derselben Befehls-ID und vollständigen Rollback | erfüllt am 23.10.2026 |
| Interner und öffentlicher Titel getrennt | Service- und Browserabläufe prüfen getrennte Werte, Snapshots und Fallback | erfüllt am 23.10.2026 |
| Entfernen betrifft nur konkrete Verwendung | Browser prüft Bestätigung, Rückgabe in Fundus und unverändertes Original | erfüllt am 23.10.2026 |
| Beliebige Live-Verwendung auswählbar | Verhaltenstests wählen ersten und erledigten Eintrag über stabile Usage-ID | erfüllt am 23.10.2026 |
| Stabiler widerrufbarer OBS-Kanal | Servicetests prüfen Wiederöffnung/Rotation; anonymer Browser prüft Fragmenttausch und Cookie | erfüllt am 23.10.2026 |
| Private Quellen und Uploads bleiben intern | HTML- und JSON-Prüfung enthält weder implizite Quellen, Uploadnamen noch interne Redaktionstexte | erfüllt am 23.10.2026 |
| Responsive und gleichwertig bedienbar | Chromium prüft Desktop/Mobil, Light/Dark, Drag-and-drop sowie Klick- und Tastaturalternativen | erfüllt am 23.10.2026 |
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
## Ausgeführte Prüfungen 0.5.8
- `npm run lint`: nach Formatierung erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 33 Tests in 6 Dateien erfolgreich
- `npm run test:e2e`: 15 von 15 Chromium-Browsertests erfolgreich
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, horizontalem Überlauf, Tastaturbedienung, Planung, Live-Steuerung
und anonymer Ausgabe geprüft; Screenshots wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run db:migrate`: Migrationen `0007_editorial_workflows.sql` und
`0008_durable_planning_commands.sql` erfolgreich auf der zuvor gesicherten
lokalen Entwicklungsdatenbank angewendet
- `bash -n start.sh`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen
## Live-Werkbank 0.5.9
| Kriterium | Nachweis | Stand |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------- |
| Favorit ist globale Originaleigenschaft | Service prüft Revision/Status; Browser prüft Stern, Sortierung und weiterhin wirksamen Planfilter | erfüllt am 23.10.2026 |
| Werkzeugkatalog und Rechte | Barkeeper-Verwaltung serverseitig geschützt; Browser prüft Anlage, Formatbezug und Deaktivierung | erfüllt am 23.10.2026 |
| Deaktivierte Zuordnung bleibt bearbeitbar | Browser öffnet das Piece erneut und findet Werkzeug, Arbeitslink, Notiz und Deaktivierungsstatus | erfüllt am 23.10.2026 |
| Relationen werden unveränderlich gesnapshottet | Service ändert Quelle, Werkzeug und Thema und sieht Änderungen erst nach expliziter Aktualisierung | erfüllt am 23.10.2026 |
| Private Live-Materialien bleiben privat | Live-Steuerung zeigt Snapshot-Quelle; anonymes HTML/JSON enthält den privaten Materiallink nicht | erfüllt am 23.10.2026 |
| Thematische Publikum-Dachzeile | Usage-Themen werden stabil projiziert; Chromium zeigt „Gaming“ oberhalb des Präsentationstitels | erfüllt am 23.10.2026 |
| Persistenter Abschnitts-Countdown | kontrollierte Testzeit prüft eingefrorenes Soll, Reload, Piece-Wechsel und Abschluss ohne Reset | erfüllt am 23.10.2026 |
| Responsive Bedienung | komplette Browsermatrix und gezielter Werkzeugeditor bei 390 × 844 ohne horizontalen Überlauf | erfüllt am 23.10.2026 |
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
## Ausgeführte Prüfungen 0.5.9
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 37 Tests in 7 Dateien erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
- zusätzlicher gezielter Chromium-Lauf des Werkzeugeditors bei 390 × 844 Pixeln:
1 von 1 erfolgreich, ohne horizontalen Überlauf
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, Planung, Live-Steuerung, anonymer Ausgabe und privater Materialien
erfolgreich; Screenshots wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- lokale Entwicklungsdatenbank vorab nach
`.data/backups/0.5.9-pre-migration/sendewerk.sqlite` gesichert;
`npm run db:migrate` hat `0009_live_workbench.sql` erfolgreich angewendet
- `bash -n start.sh` und `git diff --check`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen
## Editor-Erlebnis 0.5.10
| Kriterium | Nachweis | Stand |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Titel direkt und per Tastatur bearbeitbar | Chromium prüft Vorlage, Inline-Titel, Autosave, Schließen und erneutes Öffnen | erfüllt am 23.10.2026 |
| Gemeinsame Themen- und Werkzeugauswahl | Browser prüft Auswahl, Speicherung und deaktivierte Werkzeugzuordnung; Svelte-Check ist warnfrei | erfüllt am 23.10.2026 |
| Themenicons lokal und persistent | Migration und Servicetests prüfen Iconwert und Snapshot; Verwaltung nutzt lokalen Iconpicker | erfüllt am 23.10.2026 |
| Präsentationsbild ausdrücklich gewählt | Browser lädt Bild hoch, aktualisiert Usage und sieht nur dieses Bild in anonymer Ausgabe | erfüllt am 23.10.2026 |
| Medien- und Usage-Snapshots bleiben stabil | Servicetest entfernt Originalmedium und erhält bestehende Usage-Ausgabe | erfüllt am 23.10.2026 |
| Piece-Löschen ist wiederherstellbar | Servicetest prüft Revision, Soft Delete, Listenausblendung, Medienzugriff und Wiederherstellung | erfüllt am 23.10.2026 |
| Live-Verwendungen sicher entfernbar | Servicetests erlauben nur offene, nie gelaufene, nicht aktuelle oder präsentierte Verwendungen | erfüllt am 23.10.2026 |
| Leere OBS-Ausgabe ist transparent | Chromium prüft leeren Renderer vor und nach Präsentation sowie versteckten Verbindungsstatus | erfüllt am 23.10.2026 |
| Responsive Bedienung | vollständige Browsermatrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 23.10.2026 |
| Reales OBS, reale Anbieter und echtes iOS | In der lokalen Prüfumgebung nicht verfügbar | offen |
| Werkzeugkatalog entspricht Themenverwaltung | Kompakte Iconzeilen und gemeinsamer Anlegen-/Bearbeitendialog wurden auf Desktop/Mobil verglichen | erfüllt am 23.10.2026 |
| Vollständige Classic-Regular-Auswahl | Generierung liefert 3.671 Icons; seltenes Icon wurde gesucht, gespeichert und im Piece angezeigt | erfüllt am 23.10.2026 |
| Formatwahl nutzt gemeinsamen Picker | Browser ordnet Quiz im Dialog zu und erhält Format sowie Deaktivierungszustand nach Reload | erfüllt am 23.10.2026 |
## Ausgeführte Prüfungen 0.5.10
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 40 Tests in 7 Dateien erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
Light/Dark, mobiler Navigation, Dialogen, Planung, Live-Steuerung, anonymer
Ausgabe, Medienauswahl und privater Datenfilterung erfolgreich; Screenshots
wurden durch die Suite erzeugt
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- lokale Entwicklungsdatenbank vorab nach
`.data/backups/0.5.10-pre-migration/sendewerk.sqlite` gesichert;
`npm run db:migrate` hat `0010_editor_experience.sql` erfolgreich angewendet
- vor der Werkzeugmigration zusätzlich nach
`.data/backups/0.5.10-tool-icons-pre-migration/sendewerk.sqlite` gesichert;
`npm run db:migrate` hat `0011_tool_icons.sql` erfolgreich angewendet
- Werkzeugkatalog, Anlegen-/Bearbeitendialog, geöffnete Iconauswahl und
Piece-Werkzeugauswahl bei 1280 × 720 und 390 × 844 Pixeln visuell direkt mit
der Themenverwaltung verglichen; keine horizontale Überlauffläche festgestellt
- `bash -n start.sh`: erfolgreich
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
## Verwaltungskorrektur 0.5.11
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- |
| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 23.10.2026 |
| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 23.10.2026 |
| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 23.10.2026 |
| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 23.10.2026 |
## Kompakte Live-Regie 0.5.12
| Kriterium | Nachweis | Stand |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 |
| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 |
| Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | erfüllt am 24.10.2026 |
| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 |
| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 |
| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 |
| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 |
| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 |
| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 |
| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 |
| Wiederaufnahme nur bei Unterbrechung | Während eines aktiven Pieces fehlt die Aktion; nach unterbrochenem Stand ist sie prominent | erfüllt am 24.10.2026 |
| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 |
## Ausgeführte Prüfungen 0.5.12
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 40 Tests in 7 Dateien erfolgreich
- gezielter Live-Regie-Lauf: 1 von 1 Chromium-Test erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
- Screenshots der Live-Regie bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln
einschließlich mobilem Ablauf-Sheet visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
+255 -20
View File
@@ -31,18 +31,19 @@ spätere API dieselben Regeln verwenden.
- Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative
ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`.
## Geplantes Gesamtdatenmodell
## Gesamtdatenmodell
Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten
Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
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 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens, Sperren und technische Zugänge; Tokens nur gehasht |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
@@ -63,6 +64,212 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt.
Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt in
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
## Anmeldung und Rechte
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
`thekencrew`. Passwörter werden mit Argon2id gespeichert. Sitzungs-, Einladungs-
und Reset-Tokens entstehen aus kryptografischem Zufall; in SQLite liegt jeweils
nur ihr SHA-256-Hash. Der Browser erhält ausschließlich das rohe Sitzungstoken
in einem `HttpOnly`-, `SameSite=Lax`-Cookie.
`hooks.server.ts` löst die Sitzung für jeden Request auf. Die Datenbankabfrage
verbindet die Session immer mit dem aktuellen Nutzerzustand, sodass Sperren und
Rollenwechsel ohne verzögerte Token-Erneuerung wirken. Geschützte Routen
erfordern eine Anmeldung; Verwaltungs-Loads und jede Verwaltungs-Action prüfen
zusätzlich die Barkeeper-Rolle. Sperrung und Passwortwechsel löschen bestehende
Sessions transaktional.
Der Erstadmin benötigt sowohl eine leere Nutzertabelle als auch ein extern
konfiguriertes Setup-Token. Einladungs- und Reset-Links sind 24 Stunden gültig,
werden erst beim bestätigenden POST verbraucht und sind genau einmal nutzbar.
Fehlgeschlagene Loginversuche werden pro E-Mail-Schlüssel und Clientadresse in
einem Zeitfenster begrenzt, ohne das Konto dauerhaft zu sperren.
Mail verwendet einen Adapter: `local` schreibt geschützte Dateien in den
konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen.
## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständige
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
`is_favorite` ist eine globale Eigenschaft des Originals und keine Nutzung oder
Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate
sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten
Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle
bestehenden Zuordnungen erhält. Ein serverseitig validiertes `icon_name` gehört
zum Werkzeugkatalog und wird im Originaleditor angezeigt, ohne bestehende
Usage-Snapshots nachträglich zu verändern.
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
diese Felder nicht.
Piece-Vorlagen liegen als validierte JSON-Snapshots in `piece_templates`. Jeder
Speichervorgang kopiert den vollständigen Vorlageninhalt tief und erhöht bei
einer Aktualisierung die Revision; spätere Änderungen am Ausgangs-Piece wirken
nicht auf die Vorlage zurück. Die leere Vorlage bleibt virtuell. Interner Titel
und optionaler Präsentationstitel sind getrennte Felder; ohne öffentlichen Titel
verwendet die Präsentationsprojektion den internen Titel als Fallback.
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die
Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien.
Piece-Dauern werden in den Fundus- und Verwendungseditoren als Minuten erfasst,
an der Servergrenze auf volle Sekunden gerundet und ausschließlich als ganze
Sekunden persistiert.
Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit
sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über
Markdown eingebettet. Uploads akzeptieren höchstens 5 MB und nur anhand ihrer
Dateisignatur erkannte JPEG-, PNG-, WebP- oder GIF-Dateien. Interne Dateinamen
sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]`
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
Ein Piece kann genau eines seiner aktiven Medien ausdrücklich als
Präsentationsbild auswählen. Die Auswahl liegt in `piece_presentation_media`;
das Entfernen setzt am Medium einen Zeitstempel statt die Datei zu löschen.
Damit bleiben bereits erzeugte `usage_media_snapshots` auflösbar, während neue
Einplanungen und der Originaleditor nur aktive Medien berücksichtigen. Das
Soft Delete eines Pieces setzt ebenfalls nur `deleted_at_utc`: normale Listen
und neue Einplanungen schließen es aus, bestehende Verwendungsreferenzen und
Snapshots bleiben unverändert erhalten.
## Sendungsbau
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine
Sendung ist die Konflikt- und Transaktionsgrenze: Jede Änderung an Metadaten,
Abschnitten, Verwendungen oder Reihenfolge vergleicht die erwartete Revision und
erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt.
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
Themennamen samt Icon sowie das ausdrücklich ausgewählte Präsentationsmedium.
Eigene Notizen liegen ausschließlich an
der Verwendung. Die Referenz zum Original bleibt für Historie und
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
und den Präsentationszustand atomar aus dem referenzierten Original. Eigene
Notizen, Status und vorhandene Live-Intervalle werden dabei nicht verändert;
fehlt das Original oder ist die Sendungsrevision veraltet, wird die gesamte
Mutation verworfen.
Eine Einplanung zu duplizieren kopiert genau diesen Snapshot mit derselben
Original-ID direkt hinter das Ausgangselement. Die neue Verwendung erhält den
Status `open`, eine neue ID und damit weder Live-Referenzen noch Zeitintervalle.
Die Verwendungshistorie wird nicht separat gepflegt. Sie wird über die
Original-ID aus Verwendungen mit Status `done` in abgeschlossenen Sendungen
abgeleitet; Reservebank und alle anderen Verwendungs- oder Sendungsstatus sind
ausgeschlossen. Über dieselbe Original-ID wird unabhängig davon ermittelt, ob
mindestens eine Verwendung in einer noch nicht abgeschlossenen Sendung liegt.
Der allgemeine Fundus blendet diesen globalen Kontext standardmäßig aus. Die
Quellspalte einer Sendung betrachtet für ihren separaten Einplanungsfilter nur
die aktuelle Sendung einschließlich Reservebank.
Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch
verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die
Reserve in der öffentlichen Struktur und allen Dauerberechnungen getrennt
bleibt. Positionen werden nach jeder Transaktion kollisionsfrei normalisiert.
Eine explizite Abschnittsdauer ersetzt die Summe der Verwendungen vollständig.
Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern.
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
zurückgibt und weder ein zweites Piece noch eine zweite Einplanung erzeugt.
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
Dokument bei Planungsmutationen bestehen; nur die serverseitigen Seitendaten
werden aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen
Abschnitt oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen
entweder eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben
serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des
Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige
Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete
Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst
beim Loslassen.
Die Ausschlussfilter für gesendete und bereits eingeplante Pieces arbeiten
clientseitig auf der serverseitig berechneten Kontextmenge. Ihr Zustand wird
ohne Navigation in die URL übernommen und bei nachfolgenden Suchfiltern
mitgesendet. Planmutationen invalidieren anschließend die Seitendaten, sodass
das Entfernen der letzten Einplanung ein Original unmittelbar wieder anbietet.
## Live-Modus und Präsentationsgrenze
`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 nutzt
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im
URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie
getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den
rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
erneutes Öffnen behält ihn bei.
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
Grenze passieren.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
erst nach einer ausdrücklichen Snapshot-Aktualisierung.
Linkmetadaten werden nur auf ausdrückliche Aktion durch
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
serverseitig auf, sperrt lokale und private IPv4-/IPv6-Ziele und bindet den
Request an die geprüfte Adresse. Dieselbe Prüfung gilt nach höchstens drei
Redirects. Zeit- und Größenlimits begrenzen HTML, Anbieter-Metadaten und Bilder.
Externe Vorschaubilder gelangen nur über token- beziehungsweise
sitzungsgebundene Proxyrouten in die Ausgabe; fremdes HTML und fremde Skripte
werden nie übernommen.
Videoquellen werden aus der gespeicherten URL erneut auf eine streng validierte
YouTube- oder TikTok-ID reduziert. Der Browser erhält nur daraus erzeugte
offizielle Embed-URLs, startet sie nicht automatisch und ersetzt das Iframe bei
jedem Inhaltswechsel. YouTube erhält die Startsekunde als Embed-Parameter;
TikTok wird nach dessen Bereitschaft über die offizielle Player-`postMessage`-
Schnittstelle positioniert. Nicht unterstützte oder unvollständige Videoquellen
fallen auf eine normale Linkkarte zurück. Eine authentifizierte Testansicht
verwendet denselben Renderer, schreibt aber keinen Live-Zustand.
Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per
`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.
@@ -72,17 +279,45 @@ 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.
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
Etappe 1 zeigt Fundus, Sendungsbau und Live-Modus als sichtbare
`Gestaltungsvorschau`. Die Daten kommen bereits aus SQLite, mutierende
Fachabläufe werden jedoch nicht vorgetäuscht.
Die private Route `/live` verwendet ebenfalls einen eigenen authentifizierten
Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder
Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster
aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie
fester Aktionszeile. Die Breite entscheidet über zwei Spalten oder das mobile
Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten,
ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints
wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung
eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand
aufgehoben. Dieser Layoutmodus ändert weder die Live-Actions noch die getrennte
öffentliche `/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige
Verwendung gebunden und werden nicht zwischen Pieces übertragen.
## Sicherheitsgrenzen ab den Folgeetappen
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog
nicht in ihren Client-Chunk.
Authentifizierung und Autorisierung werden serverseitig an jeder Route und
Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits.
+283 -1
View File
@@ -1,6 +1,6 @@
# Entscheidungen
Stand der Quellenprüfung: 21. Oktober 2026. Verwendet wurden offizielle
Stand der Quellenprüfung: 22. Oktober 2026. Verwendet wurden offizielle
Dokumentationen und die npm-Registry für konkrete Paket-Metadaten.
## D-001: SvelteKit 3 und Node-Unterstützung
@@ -94,3 +94,285 @@ Der Fundus erscheint auf dem Desktop als kompakte Liste und auf kleinen
Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in
einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese
Darstellungsentscheidung ändert weder Datenmodell noch Funktionsumfang.
## D-008: Widerrufbare Datenbanksitzungen und Argon2id
Sendewerk verwendet Datenbanksitzungen statt JWT. SvelteKit empfiehlt diesen
Ansatz, wenn sofortiger Widerruf wichtiger als eine zusätzliche Datenbankabfrage
ist. Ein zufälliges Sitzungstoken liegt nur als SHA-256-Hash in SQLite; der
Browser erhält es als `HttpOnly`-, `SameSite=Lax`-Cookie. Nutzerstatus und Rolle
werden bei jedem Request erneut verbunden. Lokal erlaubt eine explizite
`http:`-Origin Cookies ohne `Secure`, bei `https:` wird `Secure` gesetzt.
Passwörter hasht `@node-rs/argon2` 2.2.2 mit Argon2id und den dokumentierten
Paketparametern. Zufallstokens und SHA-256 stammen aus `node:crypto`; eigene
Kryptoprimitive werden nicht implementiert.
Quellen:
- <https://svelte.dev/docs/kit/auth>
- <https://svelte.dev/docs/kit/hooks>
- <https://github.com/napi-rs/node-rs/tree/main/packages/argon2>
## D-009: Form-Actions, geschlossenes Setup und Rollenprüfung
Alle Auth-Schreibvorgänge verwenden POST-Form-Actions beziehungsweise einen
POST-Endpunkt. SvelteKits gleichursprüngliche Formularprüfung bleibt aktiv. Der
Erstadmin erfordert ein mindestens 32 Zeichen langes Betreiber-Setup-Token und
eine transaktional geprüfte leere Nutzertabelle. Danach bleibt `/setup`
geschlossen. Barkeeper-Rechte werden in jeder Verwaltungsaktion erneut geprüft;
UI-Sichtbarkeit ist keine Sicherheitsgrenze.
Quelle: <https://svelte.dev/docs/kit/form-actions>
## D-010: Lokale Mail-Outbox als sichere Vorgabe
Der Mailmodus ist standardmäßig `local` und schreibt Einladungs-/Resetnachrichten
in einen konfigurierten persistenten Pfad. Dadurch wird lokal keine echte Mail
ungefragt versandt. Nur die explizite Auswahl `smtp` initialisiert Nodemailer;
bei SMTP sind Host und Absender verpflichtend. Outbox-Dateien sind private
Geheimnisträger und werden weder versioniert noch über eine Route ausgeliefert.
Nodemailer 10.1.7 unterstützt Node 20 oder neuer und ist MIT-0-lizenziert.
Quelle: <https://github.com/nodemailer/nodemailer>
## D-011: Form-Actions und optimistische Piece-Revisionen
Piece-Schreibvorgänge bleiben normale SvelteKit-Form-Actions und funktionieren
damit ohne clientseitiges JavaScript. `use:enhance` ergänzt den Editor um
verzögertes Autospeichern und sichtbare Zustände. Vor interner Navigation wird
eine ausstehende Änderung gespeichert; beim Verlassen der Seite greift die
Browserwarnung. Jede Mutation vergleicht eine erwartete ganzzahlige Revision in
derselben SQLite-Transaktion. Veraltete Änderungen liefern 409 und überschreiben
keine aktuelleren Inhalte oder Zuordnungen.
Quelle: <https://svelte.dev/docs/kit/form-actions>
## D-012: Markdown immer nach dem Parsen bereinigen
Marked 18.1.1 rendert die gespeicherten Markdown-Texte serverseitig. Marked
weist ausdrücklich darauf hin, dass seine HTML-Ausgabe nicht bereinigt ist.
Darum reduziert sanitize-html 2.18.1 das Ergebnis anschließend auf ausgewählte
Text-, Listen-, Code- und Linkelemente. Skripte, Bilder, Ereignisattribute,
relative Protokoll-URLs und andere Schemata werden nicht zugelassen.
Quellen:
- <https://github.com/markedjs/marked>
- <https://github.com/apostrophecms/apostrophe/tree/main/packages/sanitize-html>
## D-013: Private Rasterbilder außerhalb statischer Assets
Uploads liegen ausschließlich im konfigurierten privaten Uploadverzeichnis und
werden über zufällige interne Namen adressiert. file-type 22.2.0 erkennt den Typ
aus dem Dateiinhalt; Client-MIME und Originalname entscheiden nicht über Typ oder
Pfad. Zugelassen sind JPEG, PNG, WebP und GIF bis 5 MB. SVG und allgemeine
Dokumente bleiben ausgeschlossen. Eine authentifizierte ID-Route liefert Bilder
mit sicherheitsrelevanten Headern aus. Das Adapter-Requestlimit liegt mit 6 MB
knapp oberhalb des fachlichen Dateilimits.
Quelle: <https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload>
## D-014: Sendung als transaktionales Aggregat
Eine ganzzahlige Revision auf `shows` schützt in Etappe 4 sämtliche Metadaten,
Abschnitte, Verwendungen und Reihenfolgen gemeinsam. Jede Mutation erhöht die
Revision in derselben SQLite-Transaktion. Damit können voneinander abhängige
Listenänderungen nicht teilweise gespeichert oder durch einen veralteten
Browserstand unbemerkt überschrieben werden.
Verwendungen speichern vollständige redaktionelle Text- und Dauerkopien. Die
Original-ID bleibt Referenz, ist aber keine Live-Datenquelle. Eine interne,
eindeutige Reservesection pro Sendung erlaubt dieselben geprüften Verschiebe-
und Löschoperationen für Plan und Reserve; Dauerabfragen schließen sie aus.
## D-015: Drag-and-drop nur als zusätzliche Bedienung
Abschnitte, Verwendungen und gefilterte Quellen können über eindeutige Griffe
gezogen werden. Dieselben Ergebnisse sind mit sichtbaren
Hoch-/Runter-Schaltflächen und Zielauswahl erreichbar. Diese Bedienelemente
funktionieren mit einfachem Klick, Touch und Tastatur auch ohne Drag-and-drop
oder clientseitige Spezialgesten. Das folgt WCAG 2.2 SC 2.5.7;
Tastaturzugang allein wäre keine ausreichende Zeigeralternative. Die
zugehörigen Form-Actions werden progressiv verbessert: Ohne JavaScript bleiben
normale POSTs möglich, mit JavaScript erfolgt die Aktualisierung ohne
Dokumentreload und mit lokalem Speicherzustand.
Quellen:
- <https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements>
- <https://svelte.dev/docs/kit/form-actions>
## D-016: Live-Befehle idempotent, tatsächliche Zeiten als Intervalle
Die Live-Steuerung verwendet eine eigene Revision und zusätzlich eine pro
Bedienaktion erzeugte Befehls-ID. Die Datenbank akzeptiert jede Kombination aus
Sendung und Befehls-ID nur einmal. Ein nach Verbindungsabbruch wiederholter
Befehl liefert dieselbe Ergebnisrevision und erzeugt weder einen zweiten
Statuswechsel noch ein zweites Zeitintervall. Laufzeit wird nicht als einzelner
überschreibbarer Wert, sondern als Summe abgeschlossener und höchstens eines
aktiven Intervalls ermittelt. Dadurch bleiben Zurückgehen und Wiederaufnahme
korrekt.
## D-017: Präsentationsausgabe als Positivliste mit gehashtem Zugang
Die heinzlive-Ausgabe ist kein eingeschränkter Aufruf der internen Live-Ansicht,
sondern eine eigene Datenprojektion. Zulässig sind ausschließlich ausdrücklich
präsentierter Titel und Präsentationstext, öffentliche Quellen sowie zugehörige
Bilder und Alternativtexte. Recherche, Einleitung, Moderation, eigene Notizen,
Ablauf, Nutzer und interne Dateinamen werden nicht in HTML oder JSON übernommen.
Ein zufälliger 256-Bit-Token steht in der URL, während SQLite nur seinen
SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und
Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`-
Polling statt über eine dauerhaft offene Serververbindung.
## D-018: Verwendung aus bestehenden Sendungsdaten ableiten
„Gesendet“ ist kein Zustand am Fundus-Piece. Die Anzeige wird ausschließlich
aus der vorhandenen Original-ID, einer erledigten Verwendung außerhalb der
Reservebank und dem Status `completed` ihrer Sendung abgeleitet. Dadurch können
Einplanung, Reserve und Überspringen ein Original nicht versehentlich als
gesendet markieren, und mehrere tatsächliche Verwendungen bleiben als Historie
sichtbar.
Eine Duplikation im Sendungsablauf kopiert den konkreten Verwendungssnapshot und
nicht das Original-Piece. Die neue ID, der Status `open` und fehlende
Live-Intervalle trennen sie von der bisherigen Durchführung. Der allgemeine
Verwendungseditor darf den Live-Status nicht mehr aus Formulardaten schreiben;
Statuswechsel bleiben der Live-Geschäftslogik vorbehalten.
Eine bestehende Einplanung wird nicht automatisch mit späteren Änderungen am
Original synchronisiert. Die ausdrücklich ausgelöste Aktion **Aus Fundus
aktualisieren** überschreibt nur Titel, Dauer und Redaktionstexte. Eigene
Notizen, Status und Messintervalle bleiben erhalten. Alle Zuordnungen und Filter
verwenden ausschließlich `original_piece_id`, niemals veränderliche Titel. Im
allgemeinen Fundus gilt „eingeplant“ global für jede nicht abgeschlossene
Sendung; im Sendungsbau gilt es lokal für die aktuelle Sendung samt Reserve.
## D-019: Lokale Linkmetadaten und streng abgeleitete Video-Embeds
Sendewerk verwendet Metascraper ausschließlich serverseitig, um Titel,
Beschreibung, Bild und Herausgeber einer ausdrücklich gewählten vorhandenen
Quelle zu ermitteln. Die Ergebnisse werden gespeichert und nicht bei jedem
Seitenaufruf neu geladen. Manuell korrigierte Felder haben Vorrang. Ein eigener
HTTP(S)-Client prüft Protokoll, Port, DNS-Ergebnisse und jedes Redirectziel,
bindet den Request an die geprüfte öffentliche Adresse und begrenzt Redirects,
Laufzeit und Antwortgröße. Damit ist weder ein externer Vorschaudienst noch die
Übernahme fremden HTMLs erforderlich.
Player werden nur für streng erkannte YouTube- und TikTok-URLs erzeugt. Aus der
validierten Anbieter-ID baut Sendewerk die offiziellen Embed-Adressen selbst;
gespeichertes oder fremd geliefertes Embed-HTML wird nicht verwendet. YouTube
nutzt die datenschutzreduzierte `youtube-nocookie.com`-Adresse und den
`start`-Parameter. TikTok erhält eine Startposition nach `onPlayerReady` über
die dokumentierte Player-`postMessage`-API. Autoplay bleibt ausgeschaltet, und
ein Inhaltswechsel ersetzt das Iframe vollständig.
Quellen:
- <https://github.com/microlinkhq/metascraper>
- <https://developers.google.com/youtube/player_parameters>
- <https://developers.google.com/youtube/terms/required-minimum-functionality>
- <https://developers.tiktok.com/doc/embed-player>
- <https://developers.tiktok.com/doc/embed-videos>
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
## D-020: Stabile Ausgabekanal-Adresse mit Fragment-Schlüssel
OBS soll eine dauerhaft konfigurierte Adresse verwenden können, ohne dass ein
Zugangsschlüssel in HTTP-Requests, Serverlogs oder Referer-Headern erscheint.
Darum bleibt die Kanaladresse `/heinzlive/default` stabil und trägt den
zufälligen Schlüssel ausschließlich als URL-Fragment. Ein kleiner Browser-POST
tauscht ihn gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie; danach greifen
Seite, Polling und erlaubte Proxys nur mit diesem Cookie zu. Für die Prüfung
liegt ein SHA-256-Hash vor. Der rohe Schlüssel muss zusätzlich in SQLite
gespeichert werden, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
nach einem Neustart erneut ausgeben können muss. Damit sind Datenbank und
Sicherungen bewusst Teil der Geheimnisgrenze. Wiederholtes Öffnen behält den
Schlüssel, eine ausdrückliche Rotation widerruft ihn sofort.
Die öffentliche Projektion folgt einer engeren Positivliste als der interne
Piece-Snapshot. Weder private Uploads noch alle Piece-Quellen werden implizit
veröffentlicht. Passieren dürfen nur öffentlicher Text, Präsentationstitel sowie
die ausdrücklich gewählte und erneut validierte Link- oder Videoquelle mit
erlaubten Vorschaufeldern.
## D-021: Vorlagen-Snapshots und idempotente Direktanlage
Persistente Piece-Vorlagen speichern validierte, tief kopierte JSON-Snapshots
mit eigener Revision. Damit bleiben sie unabhängig von späteren Änderungen am
Ausgangs-Piece, ohne für jedes optionale Piece-Feld eine zweite Tabellenstruktur
zu benötigen. Die virtuelle leere Vorlage wird nicht persistiert.
Eine Direktanlage im Sendungsplan ist ein einzelner fachlicher Befehl. Piece,
Verwendung und Sendungsrevision werden in derselben SQLite-Transaktion
geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
Einplanen getrennt in den Verwendungssnapshot kopiert.
## D-022: Werkzeuge und Live-Abschnittszeit als eigene Snapshot-Grenzen
Favoriten bleiben ein boolesches Merkmal des Fundus-Originals. Sie ändern weder
Revision, Status noch Verwendungshistorie und werden nicht pro Konto
dupliziert. Der Werkzeugkatalog ist eine eigene Entität: Formatbeziehungen
beschreiben nur die Eignung, während Piece-Zuordnungen den privaten Arbeitslink
und die Notiz tragen. Katalogeinträge werden deaktiviert statt gelöscht, damit
bestehende Piece- und Sendungsbezüge lesbar bleiben.
Beim Einplanen kopieren eigene Relationstabellen alle Quellen, Werkzeuge und
sortierten Themennamen an die Verwendung. Damit können Katalognamen, URLs,
Themen und Originalzuordnungen später geändert werden, ohne eine vorbereitete
Sendung still umzuschreiben. Nur die bewusste Aktion **Aus Fundus aktualisieren**
ersetzt diese Snapshots. Die öffentliche Positivliste darf daraus ausschließlich
die Themen-Dachzeile übernehmen; private Materiallinks und Werkzeugdaten bleiben
auf der authentifizierten Live-Route.
Der Abschnitts-Countdown verwendet von den Piece-Laufintervallen getrennte
`live_section_timers` und `live_section_timer_runs`. Beim ersten tatsächlichen
Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere
Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen
ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene
Piece-Zeiterfassung doppelt zu zählen.
## D-023: Ausdrückliche Medienauswahl und wiederherstellbares Entfernen
Ein privater Piece-Upload wird nicht allein durch seine Existenz öffentlich.
Das Original verweist ausdrücklich auf höchstens ein aktives
Präsentationsmedium. Beim Einplanen oder bewussten Aktualisieren wird diese
Auswahl mit Medium-ID und Alternativtext an die Verwendung kopiert. Die
öffentliche Route akzeptiert nur das Medium des aktuell präsentierten
Usage-Snapshots; Originalname, interner Speicherpfad und andere Uploads bleiben
außerhalb der Projektion.
Medien und Pieces werden nicht physisch gelöscht, solange bestehende
Sendungsfassungen auf ihre unveränderlichen Snapshots angewiesen sein können.
Zeitstempel blenden sie aus aktuellen Arbeitslisten und neuen Einplanungen aus,
erhalten aber Referenzen und ermöglichen die Wiederherstellung von Pieces. Eine
Verwendung oder ein Abschnitt darf dagegen nur entfernt werden, solange keine
betroffene Verwendung jemals live gelaufen ist und weder aktuell noch
präsentiert wird.
Der lokale Iconkatalog enthält die vollständige Classic-Regular-Familie mit
3.671 Icons. Ein generierter Metadatenkatalog ermöglicht Suche und serverseitige
Validierung, während ein statischer SVG-Sprite über den gemeinsamen
`currentColor`-Renderer dargestellt wird. So lädt die Navigation nicht sämtliche
Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf
noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und
Lizenz stehen in `THIRD_PARTY_NOTICES.md`.
## D-024: Private Live-Regie als fokussierter Viewport
Die authentifizierte Live-Steuerung nutzt einen eigenen Root-Layoutmodus und
rendert die allgemeine App-Navigation nicht. Kopf- und Aktionszeile bleiben fest;
Moderation und Ablauf sind die einzigen unabhängig scrollenden Arbeitsflächen.
Die Umschaltung zum mobilen Sheet hängt von der Fensterbreite ab, damit ein
breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet. Die Höhe
steuert dort unabhängig die Dichte von Abständen und Bedienleisten. Textgröße
und Gesamtoberfläche werden nicht skaliert.
Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl,
Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte
serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein
Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche
Präsentationsaction verändert die getrennte öffentliche Ausgabe.
+181
View File
@@ -0,0 +1,181 @@
# Release Notes
## 0.5.12 - Kompakte Live-Regie
Veröffentlicht am 24. Oktober 2026
Sendewerk 0.5.12 macht aus der privaten Live-Steuerung eine kompakte Regieansicht
für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Das
aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die
wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche
heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt.
### Highlights
- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale
App-Seitenleiste, mobilen App-Header oder Bottomnavigation.
- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den
tatsächlich präsentierten Ausgabezustand.
- Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen
Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer
Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar.
- Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der
verfügbaren Höhe, ohne Text oder die gesamte Oberfläche zu skalieren.
- Die feste Fußleiste bündelt nächstes Piece, „Präsentieren“, „Überspringen“ und
„Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende
eindeutig benannt.
- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet,
während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und
der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber.
### Moderation und Materialien
- Interner Titel, Einleitung, Moderationsstichpunkte und Überleitung erscheinen
in klarer Lesereihenfolge. Leere Bereiche werden weggelassen.
- Recherche, Quellen, Werkzeuge und eigene Notizen sind kompakt beschriftet und
werden nur bei Bedarf geöffnet.
- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal
gespeichert. Texte werden nicht automatisch verkleinert.
### Ablauf und Liveverhalten
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
das aktive Piece bleibt in der Ablaufspalte sichtbar.
- Pieces kennzeichnen „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch
textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags.
- Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und
die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über
eine kompakte Aktion am jeweiligen Eintrag erreichbar.
- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte
Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben.
- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts.
Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche
Ausgabe.
- Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme
verwenden unverändert die vorhandenen persistenten Zustände.
- „Live-Stand wiederaufnehmen“ erscheint nur bei einem tatsächlich
unterbrochenen Ablauf, nicht während eines aktiven Pieces.
### Einstellungen
- Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln,
Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar.
### Update und Kompatibilität
- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der
Konfiguration.
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und
Präsentationssnapshots bleiben erhalten.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
### Prüfung
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe sind erfolgreich.
- Die Live-Regie wurde bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln mit
langen Inhalten, unabhängigen Scrollflächen und mobilem Ablauf-Sheet geprüft;
die erzeugten Screenshots wurden visuell kontrolliert.
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung.
## 0.5.11 - Verwaltungskorrektur
Veröffentlicht am 23. Oktober 2026
- Der Dialog „Werkzeug hinzufügen“ bleibt beim Aufruf der Verwaltungsseite
wieder zuverlässig geschlossen.
- Der Dialog erscheint erst, wenn ein neues Werkzeug angelegt oder ein
bestehendes Werkzeug bearbeitet wird.
- Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und
Smartphone vollständig im sichtbaren Browserbereich.
- Die Korrektur gilt gleichermaßen für Desktop- und Smartphone-Ansichten und
verändert keine vorhandenen Werkzeuge oder Zuordnungen.
## 0.5.10 - Editor-Erlebnis
Veröffentlicht am 23. Oktober 2026
Sendewerk 0.5.10 beschleunigt die tägliche Redaktionsarbeit und schärft die
Grenze zwischen privaten Medien und der öffentlichen Präsentation. Titel,
Themen, Werkzeuge und Präsentationsbilder lassen sich direkter bearbeiten; die
wichtigsten Listen und Dialoge benötigen auf Desktop und Smartphone weniger
Platz. Bestehende Sendungsfassungen bleiben bei späteren Änderungen am Fundus
stabil.
### Highlights
- Titel von Fundus- und Sendungsfassungen lassen sich direkt in der jeweiligen
Ansicht ändern. Die Bedienung funktioniert auch vollständig per Tastatur.
- Themen und Werkzeuge verwenden kompakte, durchsuchbare Auswahlflächen. Der
Werkzeugkatalog folgt nun demselben Bedienmuster wie die Themenverwaltung.
- Für Themen, Werkzeuge, Navigation und Aktionen stehen 3.671 lokal
bereitgestellte Classic-Regular-Icons zur Verfügung. Zur Laufzeit werden
keine Icons von externen Diensten geladen.
- Präsentationsbilder werden bewusst aus den aktiven Piece-Medien gewählt und
beim Einplanen in die Sendungsfassung übernommen. Private Uploads werden nicht
allein durch das Hochladen öffentlich.
- Pieces lassen sich wiederherstellbar löschen. Bereits vorbereitete
Sendungsfassungen und ihre Medien-Snapshots bleiben dabei erhalten.
- Die heinzlive-Ausgabe kann ausdrücklich geleert werden. Im leeren Zustand ist
sie vollständig transparent und zeigt auch keinen Verbindungsstatus.
### Redaktion und Verwaltung
- Der Werkzeugkatalog entspricht der Themenverwaltung: kompakte Glasfläche,
Icon, Name, Status und Bearbeitungsaktion statt dauerhaft offener Formulare.
- Anlegen und Bearbeiten eines Werkzeugs verwenden denselben responsiven Dialog
für Name, Start-URL, Beschreibung, Icon, passende Formate und Aktivstatus.
- Die Formatzuordnung nutzt dieselbe durchsuchbare Mehrfachauswahl wie andere
Zuordnungen. Deaktivierte Werkzeuge und bestehende Piece-Zuordnungen bleiben
erhalten.
- Fundus, Eingang, Archiv, Vorlagen und Sendungsansichten sind kompakter und
bleiben per Klick, Touch und Tastatur bedienbar.
### Pieces und Präsentation
- Präsentationsbilder werden ausdrücklich aus aktiven Piece-Medien ausgewählt
und unveränderlich an Sendungsfassungen kopiert.
- Entfernte Medien bleiben für bestehende Sendungsfassungen verfügbar, werden
aber nicht mehr für neue Einplanungen angeboten.
- Pieces können wiederherstellbar gelöscht werden. Offene, nie live gelaufene
Verwendungen und Abschnitte lassen sich unter geschützten Bedingungen
entfernen.
- Themenicons werden zusammen mit der jeweiligen Sendungsfassung gespeichert.
Spätere Änderungen an Thema, Werkzeug oder Original-Piece verändern eine
vorbereitete Sendung weiterhin nur nach ausdrücklicher Aktualisierung aus dem
Fundus.
### Live-Ausgabe
- Eine leere heinzlive-Ausgabe ist vollständig transparent und zeigt weder
Renderer noch Verbindungsstatus.
- Eine aktive Präsentation lässt sich unabhängig vom aktuell laufenden Piece
leeren.
### Update und Kompatibilität
- `0010_editor_experience.sql` ergänzt Themenicons, Medien-Snapshots und Soft
Delete.
- `0011_tool_icons.sql` ergänzt persistente Werkzeugicons mit einem sicheren
Standardwert. Werkzeug-IDs, Formatbeziehungen, Piece-Zuordnungen und
Deaktivierungszustände bleiben erhalten.
- Die Migrationen laufen beim nächsten Serverstart automatisch. Vor dem Update
sollte wie üblich eine konsistente Sicherung der SQLite-Datenbank und des
Upload-Verzeichnisses angelegt werden.
- Für dieses Release sind keine manuellen Datenkonvertierungen und keine
Änderungen an der Konfiguration erforderlich.
### Prüfung
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe erfolgreich.
- Werkzeugverwaltung, Iconauswahl und Piece-Werkzeugauswahl wurden zusätzlich
bei 1280 × 720 und 390 × 844 Pixeln visuell mit der Themenverwaltung
verglichen.
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung.
### Noch nicht enthalten
Der Abschluss und die Archivierung vollständiger Sendungen, externe
Lesefreigaben, RSS-Import und API gehören zu späteren Etappen und sind nicht Teil
von 0.5.10.
+326 -7
View File
@@ -2,8 +2,210 @@
## Aktueller Umfang
Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und
lokaler Betriebsweg sind dokumentiert. Etappe 2 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.
## Kompakte Live-Regie 0.5.12
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und
öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece,
Präsentation, Überspringen und kontextgerechtem Erledigen
- aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln
nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite
oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen
- bei niedrigen Desktopfenstern werden Abstände und Bedienleisten anhand der
verfügbaren Höhe verdichtet, ohne Text oder Gesamtoberfläche zu skalieren
- auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet
als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, direkter
Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion
erreichbar, dauerhafte Sortierpfeile entfallen
- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece
beginnt oben statt an der Position des vorherigen Textes
- Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf;
leere Moderationsbereiche werden nicht dargestellt
- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation
verwenden unverändert ihre getrennten persistenten Zustände
- die Wiederaufnahmeaktion erscheint nur bei tatsächlich unterbrochenem
Live-Stand statt während eines laufenden Pieces
Etappe 6 bleibt unverändert unbegonnen.
Die lokale Prüfmatrix umfasst 40 Verhaltenstests und 16 Chromium-Abläufe.
Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
bleiben mangels verfügbarer Umgebung offen.
## Verwaltungskorrektur 0.5.11
- der Werkzeugdialog bleibt beim Laden geschlossen und öffnet erst nach einer
bewussten Aktion
- die Themen-Iconauswahl bleibt bei mobilen und breiten Ansichten vollständig
innerhalb des Viewports
## Editor-Erlebnis 0.5.10
- direkte, tastaturbedienbare Titelbearbeitung in Fundus- und Sendungsfassungen
sowie gemeinsame kompakte Auswahlflächen für Themen und Werkzeuge
- vollständiger lokaler Classic-Regular-Katalog mit 3.671 Icons für Navigation,
Aktionen sowie frei wählbare Themen- und Werkzeugicons; durchsuchbare
Metadaten und SVG-Sprite vermeiden externe Laufzeitabhängigkeiten
- kompakter Werkzeugkatalog nach dem Muster der Themenverwaltung; Anlegen und
Bearbeiten teilen einen responsiven Dialog mit gestalteter Icon- und
Formatauswahl, bestehende Zuordnungen und Deaktivierung bleiben erhalten
- Präsentationsbilder werden bewusst aus aktiven Piece-Medien ausgewählt und als
unveränderlicher Usage-Snapshot eingeplant; private Bilder werden dadurch
weder implizit noch nach späterer Originaländerung öffentlich
- entfernte Medien bleiben für bestehende Sendungsfassungen erhalten, während
neue Einplanungen nur aktive Medien übernehmen
- wiederherstellbares Soft Delete für Pieces sowie geschütztes Entfernen offener,
nie live gelaufener Verwendungen und Abschnitte
- kompaktere Fundus-, Eingangs-, Archiv-, Vorlagen- und Sendungsansichten mit
mobilen, touch- und tastaturbedienbaren Alternativen
- transparente leere heinzlive-Ausgabe ohne sichtbaren Renderer oder
Verbindungsstatus; die Steuerung kann eine aktive Präsentation unabhängig vom
aktuellen Piece leeren
Etappe 6 bleibt unverändert unbegonnen. Die lokale Prüfmatrix umfasst 40
Verhaltenstests und 16 Chromium-Abläufe. Reales OBS, echte
YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät bleiben mangels
verfügbarer Umgebung offen.
## Live-Werkbank 0.5.9
- globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete
Favoriten bleiben sichtbar, geplante Favoriten respektieren weiterhin den
Einplanungsfilter
- Barkeeper-Werkzeugkatalog mit Start-URL, Beschreibung, passenden Formaten und
verlustfreier Deaktivierung; Arbeitslink und Notiz bleiben für die gesamte
Thekencrew im gemeinsamen Piece- und Verwendungseditor bearbeitbar
- vollständige, voneinander unabhängige Usage-Snapshots für Quellen, Werkzeuge
und alphabetisch stabilisierte Themen; spätere Original- oder
Katalogänderungen wirken erst nach bewusster Aktualisierung aus dem Fundus
- kompakte private Materialliste in der Live-Steuerung mit Arbeitslink vor
Start-URL und verständlichem Zustand ohne URL
- responsive Themen-Dachzeile in der Publikumsausgabe; ohne Thema bleibt sie
vollständig aus, während Präsentations- und interner Titel getrennt bleiben
- persistenter Abschnitts-Countdown für positive explizite Abschnittsdauern mit
eingefrorenem Soll, sekündlicher Anzeige, Goldwarnung ab zwei Minuten und
roter Überziehung ohne Ton, Blinken oder automatische Weiterschaltung
Etappe 6 bleibt unverändert unbegonnen. Reales OBS und echte
YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
Die lokale Prüfmatrix umfasst 37 Verhaltenstests und 16 Chromium-Abläufe.
Desktop, Smartphone, Light/Dark, Favoritenfilter, Werkzeug-Deaktivierung,
Snapshot-Stabilität, private Live-Materialien, öffentliche Themenprojektion und
persistente Abschnittszeit wurden geprüft.
## Redaktionelle Arbeitsabläufe 0.5.8
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
Vorlagen lassen sich aus Fundus-Pieces speichern, erneut verwenden und löschen
- atomare, idempotente Piece-Neuanlage direkt in einer Sendung einschließlich
Einplanung; fehlgeschlagene Schritte hinterlassen weder Piece noch Verwendung
- getrennte interne und öffentliche Titel an Piece und Verwendung; ein leerer
Präsentationstitel fällt kontrolliert auf den internen Titel zurück
- präzises Entfernen einzelner Verwendungen mit Bestätigung sowie zusätzlicher
Drag-and-drop-Rückgabe in den Fundusbereich
- Auswahl jedes konkreten Ablauf-Pieces in der Live-Steuerung, einschließlich
erstem und bereits erledigtem Eintrag
- stabiler OBS-Ausgabekanal `/heinzlive/default` mit geheimem URL-Fragment,
HttpOnly-Cookie-Austausch und ausdrücklicher Schlüsselrotation
- öffentliche Ausgabe als Positivliste: implizite Piece-Quellen und private
Uploads werden nicht mehr veröffentlicht; nur ausdrücklich gewählte
Link-/Videoquellen und deren erlaubte Vorschau bleiben sichtbar
Die vollständige lokale Prüfmatrix umfasst 33 Verhaltenstests und 15
Chromium-Abläufe. Desktop, Mobil, Light/Dark, Tastatur- und Touchalternativen,
Planung, Live-Ausgabe sowie anonyme Ausgabedaten wurden geprüft. Reales OBS und
echte YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
## Materialkorrektur 0.5.7
- statische Gold-/Blauviolettlichter so positioniert und verstärkt, dass sie im
eigentlichen Arbeitsbereich statt fast ausschließlich hinter der Navigation
sichtbar werden
- gemeinsame Materialtokens auf Außenglas, dunkle Innenfläche und tiefe
Ablaufkarte reduziert; verschachtelte Abschnitte, Pieces und Aktionsgruppen
werden nach innen dunkler statt heller und blaugrau
- aktive Editortabs transparent goldgetönt und private Recherche sowie
Quellen-/Materialbereiche auf transparente Nachtblauflächen zurückgeführt
- Dialog-Backdrop von starker Abdunklung und Unschärfe auf eine leichtere
Trennung reduziert, sodass die Lichtstimmung bei geöffnetem Editor erhalten
bleibt
- deckende Schreibfelder, Goldfokus, Lightmode, Mobilansicht und lesbare
Komponentenfüllungen ohne verfügbaren Blur bleiben erhalten
Die berechneten Hintergründe, Transparenzen, Filter und Begrenzungseigenschaften
wurden vor und nach der Korrektur in Chromium kontrolliert. Sendungsplanung,
Sendungstext und Recherche wurden bei 1440 × 1000 und 390 × 844 Pixeln in beiden
Themes visuell geprüft.
## Oberflächenkorrektur 0.5.6
- sichtbare statische Gold- und Blauviolettlichter hinter den halbtransparenten
Navigations- und Editorflächen
- klarere Glaswirkung in Navigation sowie Kopf, Tabs und Fuß des Piece-Editors;
Schreibflächen bleiben deckend, ruhig und goldfokussiert
- Fundus mit dauerhaft sichtbarer Suche und Neuanlage, aufklappbaren Zusatzfiltern
und inhaltsorientierten, vollständig anklickbaren Zeilen
- vorhandene Vorschaubilder beziehungsweise kleine Symbolplatzhalter sowie
gebündelte Menüs für Duplizieren und Archivieren
- bestehende Server-Actions, Filterlogik und Editor-Bereiche unverändert
Geprüft wurden `npm run check` sowie Fundus und geöffneter Piece-Editor in
Chromium bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark. Die gezielte
Kontrolle umfasste Tastaturöffnung, Menüs, Fokus, Glaswerte, Seitenüberlauf und
visuell geprüfte Screenshots. Produktionsbuild und vollständige Testmatrix wurden
gemäß Auftrag nicht ausgeführt.
## Oberflächenkorrektur 0.5.5
- dunkle Grundfläche mit sehr zurückhaltenden Gold-/Cyan-Lichtfeldern innerhalb
der Anwendung statt gleichmäßig deckender Blauflächen
- Navigation, große Arbeitsflächen und Piece-Dialog als dunkles beziehungsweise
helles Glas mit feiner Lichtkante, Unschärfe und ruhigem Schatten
- Gold für Dialogkontur, Fokus, aktive Zustände und Hauptaktionen; Cyan nur noch
für ergänzende redaktionelle Hinweise und Links
- klar abgegrenzte, weitgehend deckende Schreibfelder mit Goldfokus, Platzhaltern
und nach Textrolle abgestuften Höhen
- bestehende nachträgliche CSS-Override-Schicht entfernt und die Gestaltung in
gemeinsame Tokens und ursprüngliche Komponentenregeln zurückgeführt
Geprüft wurden Typen, Produktionsbuild sowie gezielte Chromium-Ansichten von
Eingang, Fundus, Piece-Editor, Sendungsplanung und Live-Steuerung bei 1440 × 1000
und 390 × 844 Pixeln in beiden Themes. Die vollständige Unit- und E2E-Matrix
bleibt wie beauftragt Schritt 3 vorbehalten.
## Oberflächenrelease 0.5.4
- gemeinsame Light-/Dark-Tokens für ruhige Arbeitsflächen, Cyan als
Interaktionsfarbe und Gold für die jeweilige Hauptaktion
- Darkmode im heinzlive-Farbraum mit Hintergrund `#0B1220`, Flächen `#152238`,
Text `#FFF7E8`, Sekundärtext `#A9B6C8`, Gold `#E7B957`, Cyan `#67DDE8` und
Grenzen `#30435C`
- vereinheitlichte Navigation, Formulare, Meldungen, Listen, Sendungsplan,
Live-Steuerung und Piece-Dialoge ohne Änderung der Bedienlogik
- kompaktere Planzeilen mit sichtbarem Drag-Griff und waagerechter Aktionsgruppe;
Klick-, Touch- und Tastaturalternativen bleiben erhalten
- verständliche Kontextnamen „Fundusfassung“ und „Sendungsfassung“ sowie weniger
technische Erläuterungen im redaktionellen Arbeitsfluss
- dezente Speicheraktion im gespeicherten Zustand und hervorgehobene Aktion bei
ungespeicherten Änderungen, Fehlern oder Konflikten
Für diesen Design-Release wurden `npm run check`, `npm run build` und gezielte
Chromium-Sichtkontrollen auf Eingang, Fundus, Piece-Editor, Sendungsplan und
Live-Steuerung bei 1440 × 1000 sowie 390 × 844 Pixeln in Light/Dark ausgeführt.
Konsolenfehler und horizontaler Seitenüberlauf traten dabei nicht auf. Die
vollständige Unit- und E2E-Matrix wurde für die rein visuelle Änderung bewusst
nicht erneut ausgeführt; die fachlichen Nachweise der Etappen 1 bis 5 bleiben
unverändert.
## Umgesetzter Stand Etappe 1
@@ -34,12 +236,120 @@ Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen.
## Nicht Bestandteil dieser Etappe
## Noch nicht umgesetzt
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung,
Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
werden.
Archivabschluss, Freigaben, RSS und API. Diese späteren Bereiche dürfen nicht
als bereits implementiert verstanden werden.
## Umgesetzter Stand Etappe 2
- einmalige Erstadmin-Einrichtung mit externem Betreiber-Token
- Login und POST-Logout mit widerrufbaren Datenbanksitzungen
- Rollen Barkeeper und Thekencrew mit serverseitiger Prüfung
- Einladungen und Passwortreset über befristete, einmalige Links
- Kontensperrung mit sofortigem Entzug bestehender Sitzungen
- zeitfensterbasiertes Login-Rate-Limit ohne dauerhafte Kontensperre
- lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand
- responsive Authentifizierungs- und Kontenverwaltungsoberflächen
## Umgesetzter Stand Etappe 3
- persistente Piece-Neuanlage aus leerem Entwurf oder drei redaktionellen Vorlagen
- Schnellaufnahme von Gedanken und HTTP-/HTTPS-Links im Eingang
- vollständiger Editor für Quellen, Dauer in Minuten, Status und getrennte
Einleitung, Recherche, Moderation, Überleitung und Präsentation
- serverseitig bereinigte Markdown-Vorschau
- unabhängige Themen, Formate und Abschnittskategorien mit Barkeeper-Verwaltung
- serverseitige Suche und Filter nach Status, Format, Thema und Abschnitt
- Autospeichern mit sichtbarem Zustand und Warten vor interner Navigation
- optimistische Revisionen verhindern unbemerktes Überschreiben
- Duplizieren einschließlich Quellen, Taxonomie und Bildern
- Archivieren und Wiederherstellen in den vorherigen Arbeitsstatus
- private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung
- erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen
- standardmäßig ausgeblendete gesendete Pieces mit verlinkter, aufklappbarer
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
Original sowie Verwendung bleiben getrennte Revisionsgrenzen
- Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
- Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
Vorschau stehen gemeinsam im Präsentationsbereich
- der Dialog bleibt innerhalb der Desktop-Fensterhöhe, hält Kopf,
Bereichsnavigation und Aktionen sichtbar und nutzt mobil die volle
Bildschirmfläche; Bereichswechsel hängen Eingaben nicht aus und merken die
jeweilige Scrollposition
- die Schnellaufnahme akzeptiert Gedanke oder URL, bleibt nach dem Ablegen im
Eingang und öffnet die Ausarbeitung erst über eine bewusste Aktion
Für diese gezielte Überarbeitung wurden `npm run check` sowie kurze
Chromium-Sichtkontrollen des Original- und Verwendungseditors bei 1440 × 1000
und 390 × 844 Pixeln ausgeführt. Dabei waren Dialoggrenzen, feste Bedienbereiche,
alle drei Arbeitsbereiche und horizontaler Überlauf unauffällig. Die vollständige
Unit-, E2E- und Build-Matrix ist gemäß Auftrag noch offen.
## Umgesetzter Stand Etappe 4
- Sendungsübersicht mit leerem Start, Standardablauf und Spieleabend-Vorlage
- frei benennbare, kategorisierte und sortierbare Abschnitte einschließlich
eigenständiger Spieleblöcke
- Einplanung ausschließlich sendefertiger Pieces als unabhängige Text- und
Dauerkopien; Mehrfachverwendung desselben Originals ist möglich
- showweite Reservebank, die nicht in Abschnitts- oder Gesamtdauer einfließt
- explizite Abschnittsdauer ersetzt die Piece-Summe statt sie zu addieren
- Bearbeiten, Entfernen sowie Verschieben innerhalb und zwischen Abschnitten
- zugänglicher Stift-Dialog für alle sendungsspezifischen Felder und Aktionen,
auf Desktop zweispaltig und auf kleinen Bildschirmen einspaltig scrollbar
- explizites Aktualisieren eines Verwendungssnapshots aus dem Original für
Titel, Dauer und Redaktionstexte bei erhaltenen Notizen und Live-Daten
- Duplizieren einer konkreten Einplanung direkt hinter dem Ausgangselement mit
erhaltenen Anpassungen, aber zurückgesetztem Live-Zustand
- Piece-Dauern werden im Editor in Minuten eingegeben und intern weiterhin als
ganze Sekunden gespeichert
- Drag-and-drop über eindeutige Griffe, auch direkt aus gefilterten Quellen,
mit positionsgenauer Einfügevorschau, transparentem Original und
gleichwertigen Klick-, Touch- und Tastaturaktionen
- Planungsmutationen ohne Dokumentreload mit lokalem Speicherzustand und
animierter Neusortierung der betroffenen Elemente
- unabhängige Sofortfilter für verwendete sowie in der aktuellen Sendung schon
eingeplante Originale einschließlich Reservebank
- kopierbare Twitch-Titel und Ankündigungstexte
- showweite optimistische Revision schützt Metadaten, Reihenfolge und Inhalte
vor unbemerktem parallelem Überschreiben
- realistischer Seed mit Standardablauf und eigenständigem Spieleabend
## Umgesetzter Stand Etappe 5
- fokussierte Moderationsansicht mit Prompter-Schrift, Einleitung, Moderation,
Überleitung und standardmäßig geschlossener privater Recherche
- ausblendbare Zeit- und Ablaufanzeige sowie persistente lokale Leseposition
- aktuelles und nächstes Piece samt Abschnitt und geplantem Zeitbedarf
- persistenter Live-Stand mit tatsächlichen Laufintervallen, Erledigen,
Überspringen, Zurück und Wiederaufnahme
- idempotente Live-Befehle verhindern doppelte Wirkung bei Wiederholung
- serverseitig geschützte Umsortierung offener Pieces und Reserveeinfügung
- explizite Präsentationsaktion; normale Live-Navigation verändert die Ausgabe
nicht
- getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige,
Präsentationstext, Quellen und erlaubten Bildern
- wählbare Text-, Linkkarten- und Videodarstellung aus genau einer vorhandenen
Piece-Quelle mit manuell korrigierbaren, persistenten Metadaten
- ausdrückliches serverseitiges Neuladen von Linkmetadaten über Metascraper mit
DNS-, Redirect-, Zeit-, Größen- und Privatnetzschutz
- validierte YouTube- und TikTok-IDs, offizielle Player-Iframes ohne Autoplay
sowie Linkkarten-Fallback für nicht unterstützte Videoquellen
- unveränderliche Präsentationssnapshots je Einplanung und explizite
Aktualisierung aus dem Fundus
- isolierte Präsentationstestansicht ohne Änderung des Live-Stands sowie
tokengebundene, serverseitig geschützte Proxys für externe Vorschaubilder
- Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche,
Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade
## Bekannte offene Betriebsprüfung
@@ -49,6 +359,15 @@ 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.
Die automatisierten Playerprüfungen fingen YouTube- und TikTok-Requests lokal
ab. URL-Erkennung, Iframe-Wechsel, Zeitmarkenübergabe und mobile Ausgabe sind
damit geprüft, nicht jedoch die reale Wiedergabe oder Verfügbarkeit der Anbieter.
## Verifizierte Umgebung
- Node.js 22.23.1, npm 10.9.8
+58
View File
@@ -0,0 +1,58 @@
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('barkeeper', 'thekencrew')),
locked_at_utc TEXT,
password_changed_at_utc TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX sessions_user_idx ON sessions(user_id);
CREATE INDEX sessions_expiry_idx ON sessions(expires_at_utc);
CREATE TABLE invitations (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('barkeeper', 'thekencrew')),
token_hash TEXT NOT NULL UNIQUE,
created_by_user_id TEXT NOT NULL REFERENCES users(id),
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
accepted_at_utc TEXT,
revoked_at_utc TEXT
);
CREATE INDEX invitations_email_idx ON invitations(email_normalized, created_at_utc);
CREATE TABLE password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
consumed_at_utc TEXT
);
CREATE INDEX password_resets_user_idx ON password_reset_tokens(user_id, created_at_utc);
CREATE TABLE login_attempts (
id TEXT PRIMARY KEY,
email_key TEXT NOT NULL,
address_key TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX login_attempts_email_idx ON login_attempts(email_key, attempted_at_utc);
CREATE INDEX login_attempts_address_idx ON login_attempts(address_key, attempted_at_utc);
+23
View File
@@ -0,0 +1,23 @@
ALTER TABLE pieces ADD COLUMN research_text TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1);
ALTER TABLE pieces ADD COLUMN archived_from_status TEXT CHECK (
archived_from_status IS NULL OR archived_from_status IN ('inbox', 'preparing', 'ready')
);
CREATE TABLE piece_media (
id TEXT PRIMARY KEY,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
storage_name TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp', 'image/gif')),
byte_size INTEGER NOT NULL CHECK (byte_size > 0 AND byte_size <= 5242880),
alt_text TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
created_at_utc TEXT NOT NULL,
UNIQUE (piece_id, position)
);
CREATE INDEX idx_piece_media_piece ON piece_media(piece_id, position);
CREATE INDEX idx_piece_topics_topic ON piece_topics(topic_id, piece_id);
CREATE INDEX idx_piece_categories_category ON piece_category_suggestions(category_id, piece_id);
CREATE INDEX idx_piece_sources_piece ON piece_sources(piece_id, position);
+152
View File
@@ -0,0 +1,152 @@
CREATE TABLE shows_v4 (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
starts_at_utc TEXT,
twitch_title TEXT NOT NULL DEFAULT '',
announcement_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'live', 'completed')),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE show_sections_v4 (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows_v4(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
explicit_duration_seconds INTEGER CHECK (
explicit_duration_seconds IS NULL OR
(explicit_duration_seconds >= 0 AND explicit_duration_seconds <= 86400)
),
is_reserve_bank INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve_bank IN (0, 1)),
UNIQUE (show_id, position)
);
CREATE TABLE piece_usages_v4 (
id TEXT PRIMARY KEY,
show_section_id TEXT NOT NULL REFERENCES show_sections_v4(id) ON DELETE CASCADE,
original_piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
status TEXT NOT NULL CHECK (status IN ('open', 'current', 'done', 'skipped')),
duration_seconds INTEGER NOT NULL CHECK (duration_seconds >= 0 AND duration_seconds <= 86400),
title_snapshot TEXT NOT NULL,
intro_snapshot TEXT NOT NULL DEFAULT '',
research_snapshot TEXT NOT NULL DEFAULT '',
moderation_snapshot TEXT NOT NULL DEFAULT '',
transition_snapshot TEXT NOT NULL DEFAULT '',
presentation_snapshot TEXT NOT NULL DEFAULT '',
own_notes TEXT NOT NULL DEFAULT '',
is_reserve INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve IN (0, 1)),
UNIQUE (show_section_id, position)
);
INSERT INTO shows_v4 (
id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc
)
SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, 1,
created_at_utc, updated_at_utc
FROM shows;
INSERT INTO show_sections_v4 (
id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank
)
SELECT id, show_id, name, category_id, position, explicit_duration_seconds, 0
FROM show_sections;
INSERT INTO show_sections_v4 (
id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank
)
SELECT 'reserve-bank-' || id, id, 'Reservebank', NULL,
COALESCE((SELECT max(position) + 1 FROM show_sections WHERE show_id = shows.id), 0), NULL, 1
FROM shows;
INSERT INTO piece_usages_v4 (
id, show_section_id, original_piece_id, position, status, duration_seconds,
title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve
)
SELECT
u.id,
CASE WHEN u.is_reserve = 1 THEN 'reserve-bank-' || s.show_id ELSE u.show_section_id END,
u.original_piece_id,
CASE WHEN u.is_reserve = 1 THEN
(
SELECT count(*) - 1
FROM piece_usages earlier
JOIN show_sections earlier_section ON earlier_section.id = earlier.show_section_id
WHERE earlier.is_reserve = 1 AND earlier_section.show_id = s.show_id
AND (earlier_section.position < s.position OR
(earlier_section.position = s.position AND earlier.position < u.position) OR
(earlier_section.position = s.position AND earlier.position = u.position AND earlier.id <= u.id))
)
ELSE u.position END,
u.status,
u.duration_seconds,
u.title_snapshot,
u.intro_snapshot,
COALESCE(p.research_text, ''),
u.moderation_snapshot,
u.transition_snapshot,
u.presentation_snapshot,
'',
u.is_reserve
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
LEFT JOIN pieces p ON p.id = u.original_piece_id;
DROP TABLE piece_usages;
DROP TABLE show_sections;
DROP TABLE shows;
ALTER TABLE shows_v4 RENAME TO shows;
ALTER TABLE show_sections_v4 RENAME TO show_sections;
ALTER TABLE piece_usages_v4 RENAME TO piece_usages;
CREATE INDEX idx_shows_status_start ON shows(status, starts_at_utc);
CREATE INDEX idx_sections_show ON show_sections(show_id, position);
CREATE INDEX idx_usages_section ON piece_usages(show_section_id, position);
CREATE UNIQUE INDEX idx_one_reserve_bank_per_show
ON show_sections(show_id) WHERE is_reserve_bank = 1;
CREATE TABLE show_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE show_template_sections (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES show_templates(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
explicit_duration_seconds INTEGER CHECK (
explicit_duration_seconds IS NULL OR
(explicit_duration_seconds >= 0 AND explicit_duration_seconds <= 86400)
),
UNIQUE (template_id, position)
);
CREATE INDEX idx_show_template_sections_template
ON show_template_sections(template_id, position);
INSERT INTO show_templates (id, name, description) VALUES
('template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'),
('template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.');
INSERT INTO show_template_sections (
id, template_id, name, category_id, position, explicit_duration_seconds
) VALUES
('template-default-start', 'template-default', 'Start', NULL, 0, 300),
('template-default-jc-reaction', 'template-default', 'JC / Reaction', NULL, 1, NULL),
('template-default-game-1', 'template-default', 'Spiel 1', NULL, 2, 3600),
('template-default-jc', 'template-default', 'JC', NULL, 3, NULL),
('template-default-game-2', 'template-default', 'Spiel 2', NULL, 4, 3600),
('template-games-start', 'template-games', 'Start', NULL, 0, 300),
('template-games-game-1', 'template-games', 'Spiel 1', NULL, 1, 2700),
('template-games-break', 'template-games', 'Pause & Chat', NULL, 2, 600),
('template-games-game-2', 'template-games', 'Spiel 2', NULL, 3, 2700),
('template-games-game-3', 'template-games', 'Spiel 3', NULL, 4, 2700);
+45
View File
@@ -0,0 +1,45 @@
CREATE TABLE show_live_states (
show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
current_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL,
started_at_utc TEXT,
ended_at_utc TEXT,
presentation_usage_id TEXT REFERENCES piece_usages(id) ON DELETE SET NULL,
presentation_revision INTEGER NOT NULL DEFAULT 0 CHECK (presentation_revision >= 0),
updated_at_utc TEXT NOT NULL
);
CREATE TABLE live_usage_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE UNIQUE INDEX idx_one_active_live_run_per_show
ON live_usage_runs(show_id) WHERE ended_at_utc IS NULL;
CREATE INDEX idx_live_runs_usage ON live_usage_runs(usage_id, started_at_utc);
CREATE TABLE live_commands (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
action TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 0),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
CREATE TABLE presentation_access_tokens (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at_utc TEXT NOT NULL,
revoked_at_utc TEXT
);
CREATE INDEX idx_presentation_tokens_show
ON presentation_access_tokens(show_id, revoked_at_utc);
UPDATE piece_usages SET status = 'open' WHERE status = 'current';
+28
View File
@@ -0,0 +1,28 @@
ALTER TABLE pieces ADD COLUMN presentation_mode TEXT NOT NULL DEFAULT 'text'
CHECK (presentation_mode IN ('text', 'link', 'video'));
ALTER TABLE pieces ADD COLUMN presentation_source_position INTEGER;
ALTER TABLE pieces ADD COLUMN presentation_title TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_description TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_image_url TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_site_name TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_provider TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_provider_id TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN presentation_title_manual INTEGER NOT NULL DEFAULT 0
CHECK (presentation_title_manual IN (0, 1));
ALTER TABLE pieces ADD COLUMN presentation_description_manual INTEGER NOT NULL DEFAULT 0
CHECK (presentation_description_manual IN (0, 1));
ALTER TABLE pieces ADD COLUMN presentation_image_manual INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_manual IN (0, 1));
ALTER TABLE pieces ADD COLUMN presentation_metadata_fetched_at_utc TEXT;
ALTER TABLE piece_usages ADD COLUMN presentation_mode_snapshot TEXT NOT NULL DEFAULT 'text'
CHECK (presentation_mode_snapshot IN ('text', 'link', 'video'));
ALTER TABLE piece_usages ADD COLUMN presentation_source_label_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_source_url_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_source_timecode_snapshot INTEGER;
ALTER TABLE piece_usages ADD COLUMN presentation_title_meta_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_description_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_image_url_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_site_name_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_provider_snapshot TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN presentation_provider_id_snapshot TEXT NOT NULL DEFAULT '';
+58
View File
@@ -0,0 +1,58 @@
ALTER TABLE pieces ADD COLUMN output_title TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN output_title_snapshot TEXT NOT NULL DEFAULT '';
CREATE TABLE piece_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
snapshot_json TEXT NOT NULL CHECK (json_valid(snapshot_json)),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
INSERT INTO piece_templates (
id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc
) VALUES
(
'talk', 'Gespräch',
'Leitfragen, Recherche und eine klare Überleitung.',
'{"title":"Unbenanntes Gespräch","formatId":"format-talk","moderationNotes":"## Leitfragen\n\n- ","categoryIds":["cat-jc"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
),
(
'find', 'Reaction / Fundstück',
'Quelle, Einordnung und präsentierbare Kernaussage.',
'{"title":"Neues Fundstück","formatId":"format-find","presentationText":"## Kernaussage\n\n","categoryIds":["cat-reaction"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
),
(
'interaction', 'Interaktion',
'Quiz, Publikumsfrage oder kleines Spiel.',
'{"title":"Neue Interaktion","formatId":"format-quiz","moderationNotes":"## Ablauf\n\n1. ","categoryIds":["cat-game"]}',
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
);
CREATE TABLE planning_commands (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
CREATE TABLE presentation_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
access_key TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
show_id TEXT REFERENCES shows(id) ON DELETE SET NULL,
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
UPDATE presentation_access_tokens
SET revoked_at_utc = COALESCE(revoked_at_utc, '2026-10-23T00:00:00.000Z');
@@ -0,0 +1,18 @@
CREATE TABLE planning_commands_durable (
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
piece_id TEXT NOT NULL,
usage_id TEXT NOT NULL,
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (show_id, command_id)
);
INSERT INTO planning_commands_durable (
show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
)
SELECT show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
FROM planning_commands;
DROP TABLE planning_commands;
ALTER TABLE planning_commands_durable RENAME TO planning_commands;
+102
View File
@@ -0,0 +1,102 @@
ALTER TABLE pieces ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0
CHECK (is_favorite IN (0, 1));
CREATE TABLE tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
start_url TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE tool_formats (
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
format_id TEXT NOT NULL REFERENCES formats(id) ON DELETE CASCADE,
PRIMARY KEY (tool_id, format_id)
);
CREATE TABLE piece_tools (
id TEXT PRIMARY KEY,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE RESTRICT,
work_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
UNIQUE (piece_id, tool_id),
UNIQUE (piece_id, position)
);
CREATE TABLE usage_source_snapshots (
id TEXT PRIMARY KEY,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
label TEXT NOT NULL,
url TEXT NOT NULL,
timecode_seconds INTEGER CHECK (timecode_seconds IS NULL OR timecode_seconds >= 0),
position INTEGER NOT NULL CHECK (position >= 0),
UNIQUE (usage_id, position)
);
CREATE TABLE usage_tool_snapshots (
id TEXT PRIMARY KEY,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
original_tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL,
name TEXT NOT NULL,
start_url TEXT NOT NULL DEFAULT '',
work_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
UNIQUE (usage_id, position)
);
CREATE TABLE usage_topic_snapshots (
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL CHECK (position >= 0),
PRIMARY KEY (usage_id, position)
);
CREATE TABLE live_section_timers (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
section_id TEXT NOT NULL REFERENCES show_sections(id) ON DELETE CASCADE,
planned_duration_seconds INTEGER NOT NULL CHECK (planned_duration_seconds > 0),
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE TABLE live_section_timer_runs (
id TEXT PRIMARY KEY,
timer_id TEXT NOT NULL REFERENCES live_section_timers(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 INDEX idx_piece_tools_piece ON piece_tools(piece_id, position);
CREATE INDEX idx_tool_formats_format ON tool_formats(format_id, tool_id);
CREATE INDEX idx_usage_sources_usage ON usage_source_snapshots(usage_id, position);
CREATE INDEX idx_usage_tools_usage ON usage_tool_snapshots(usage_id, position);
CREATE UNIQUE INDEX idx_one_open_section_timer_per_show
ON live_section_timers(show_id) WHERE ended_at_utc IS NULL;
CREATE UNIQUE INDEX idx_one_open_run_per_section_timer
ON live_section_timer_runs(timer_id) WHERE ended_at_utc IS NULL;
INSERT INTO usage_source_snapshots (
id, usage_id, label, url, timecode_seconds, position
)
SELECT lower(hex(randomblob(16))), u.id, ps.label, ps.url, ps.timecode_seconds, ps.position
FROM piece_usages u
JOIN piece_sources ps ON ps.piece_id = u.original_piece_id;
INSERT INTO usage_topic_snapshots (usage_id, name, position)
SELECT usage_id, name, position
FROM (
SELECT u.id AS usage_id, t.name,
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
FROM piece_usages u
JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
JOIN topics t ON t.id = pt.topic_id
);
+49
View File
@@ -0,0 +1,49 @@
ALTER TABLE topics ADD COLUMN icon_name TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN deleted_at_utc TEXT;
ALTER TABLE piece_media ADD COLUMN removed_at_utc TEXT;
CREATE TABLE piece_presentation_media (
piece_id TEXT PRIMARY KEY REFERENCES pieces(id) ON DELETE CASCADE,
media_id TEXT NOT NULL UNIQUE REFERENCES piece_media(id) ON DELETE RESTRICT
);
CREATE TRIGGER validate_piece_presentation_media_insert
BEFORE INSERT ON piece_presentation_media
WHEN NOT EXISTS (
SELECT 1 FROM piece_media
WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'presentation media does not belong to piece');
END;
CREATE TRIGGER validate_piece_presentation_media_update
BEFORE UPDATE ON piece_presentation_media
WHEN NOT EXISTS (
SELECT 1 FROM piece_media
WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'presentation media does not belong to piece');
END;
ALTER TABLE usage_topic_snapshots ADD COLUMN icon_name TEXT NOT NULL DEFAULT '';
CREATE TABLE usage_media_snapshots (
id TEXT PRIMARY KEY,
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
original_media_id TEXT NOT NULL REFERENCES piece_media(id) ON DELETE RESTRICT,
alt_text TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
is_presentation INTEGER NOT NULL DEFAULT 0 CHECK (is_presentation IN (0, 1)),
UNIQUE (usage_id, position)
);
CREATE INDEX idx_pieces_deleted_at ON pieces(deleted_at_utc);
CREATE INDEX idx_piece_media_active ON piece_media(piece_id, position)
WHERE removed_at_utc IS NULL;
CREATE INDEX idx_usage_media_usage ON usage_media_snapshots(usage_id, position);
CREATE UNIQUE INDEX idx_one_presentation_media_per_usage
ON usage_media_snapshots(usage_id) WHERE is_presentation = 1;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE tools ADD COLUMN icon_name TEXT NOT NULL DEFAULT 'wrench';
+2126 -15
View File
File diff suppressed because it is too large. Load diff
+16 -5
View File
@@ -1,15 +1,15 @@
{
"name": "sendewerk",
"private": true,
"version": "0.1.1",
"version": "0.5.12",
"type": "module",
"engines": {
"node": ">=22.17 <25"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"dev": "npm run db:migrate && node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js dev",
"build": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js build",
"preview": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js preview",
"start": "npm run db:migrate && node --env-file-if-exists=.env build",
"setup": "npm run db:migrate && npm run db:seed",
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
@@ -24,7 +24,17 @@
"test:e2e": "playwright test"
},
"dependencies": {
"better-sqlite3": "^13.0.3"
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"metascraper": "^5.58.5",
"metascraper-description": "^5.58.4",
"metascraper-image": "^5.58.4",
"metascraper-publisher": "^5.58.4",
"metascraper-title": "^5.58.4",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -34,6 +44,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
+10 -1
View File
@@ -3,7 +3,11 @@ import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const dataDirectory = mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
const dataDirectory =
process.env.SENDEWERK_E2E_DATA_DIR || mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
const mailOutboxDirectory = path.join(dataDirectory, 'mail-outbox');
process.env.SENDEWERK_E2E_DATA_DIR = dataDirectory;
process.env.SENDEWERK_E2E_OUTBOX_DIR = mailOutboxDirectory;
export default defineConfig({
testDir: 'tests',
@@ -18,6 +22,11 @@ export default defineConfig({
env: {
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'),
SENDEWERK_MAIL_OUTBOX_DIR: mailOutboxDirectory,
SENDEWERK_MAIL_TRANSPORT: 'local',
SENDEWERK_APP_ORIGIN: 'http://127.0.0.1:4173',
SENDEWERK_SETUP_TOKEN: 'e2e-only-setup-token-32-characters',
ORIGIN: 'http://127.0.0.1:4173',
HOST: '127.0.0.1',
PORT: '4173'
}
@@ -0,0 +1,51 @@
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const sourceRoot = process.argv[2];
if (!sourceRoot) {
throw new Error('Usage: node scripts/generate-classic-regular-icons.mjs <font-awesome-web-root>');
}
const javascript = readFileSync(path.join(sourceRoot, 'js', 'regular.js'), 'utf8');
const metadata = JSON.parse(readFileSync(path.join(sourceRoot, 'metadata', 'icons.json'), 'utf8'));
const start = javascript.indexOf(' var icons = ') + ' var icons = '.length;
const end = javascript.indexOf('\n };', start) + '\n }'.length;
if (start < ' var icons = '.length || end < start)
throw new Error('Classic Regular icons not found');
const definitions = JSON.parse(javascript.slice(start, end));
const icons = Object.keys(definitions)
.map((name) => {
const details = metadata[name];
return {
name,
label: details?.label ?? name.replaceAll('-', ' '),
terms: details?.search?.terms ?? []
};
})
.sort((left, right) => left.label.localeCompare(right.label, 'en'));
const output = `// Generated from Font Awesome Pro Plus 7.0.0 Classic Regular metadata.
// Covered by the Font Awesome Pro commercial license; see THIRD_PARTY_NOTICES.md.
export type ClassicRegularIcon = {
\tname: string;
\tlabel: string;
\tterms: readonly string[];
};
export const classicRegularIcons: readonly ClassicRegularIcon[] = ${JSON.stringify(icons)};
export type IconName = string;
export const classicRegularIconNames = new Set<string>(
classicRegularIcons.map((icon) => icon.name)
);
`;
writeFileSync('src/lib/icons/classic-regular.ts', output);
mkdirSync('static/icons', { recursive: true });
copyFileSync(
path.join(sourceRoot, 'sprites-full', 'regular.svg'),
'static/icons/classic-regular.svg'
);
console.log(`Generated ${icons.length} Classic Regular icons.`);
+183 -10
View File
@@ -124,9 +124,49 @@ export function seed(databasePath = getPaths().database) {
'Pixel als bewusste Entscheidung.',
timestamp,
timestamp
],
[
'piece-inbox-link',
'Indie-Showcase nachhören',
'Ein Fundstück aus dem letzten Showcase, das noch eingeordnet werden muss.',
'format-find',
'inbox',
0,
'',
'',
'',
'',
timestamp,
timestamp
],
[
'piece-archive-demo',
'Controller mit eingebautem Bildschirm',
'Ein früheres Fundstück, das bewusst im Archiv liegt.',
'format-find',
'archived',
300,
'',
'',
'',
'',
timestamp,
timestamp
]
]
);
database
.prepare('UPDATE pieces SET research_text = ? WHERE id = ?')
.run(
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'piece-console'
);
database
.prepare('UPDATE pieces SET presentation_text = ? WHERE id = ?')
.run(
'## Weniger Leistung, mehr Idee\n\nDie Kurbel ist Teil des Spiels, nicht nur Dekoration.',
'piece-console'
);
database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%');
const addTopic = database.prepare(
@@ -137,7 +177,9 @@ export function seed(databasePath = getPaths().database) {
['piece-effects', 'topic-film'],
['piece-quiz', 'topic-gaming'],
['piece-pixels', 'topic-gaming'],
['piece-pixels', 'topic-internet']
['piece-pixels', 'topic-internet'],
['piece-inbox-link', 'topic-gaming'],
['piece-archive-demo', 'topic-gaming']
])
addTopic.run(...values);
@@ -149,7 +191,9 @@ export function seed(databasePath = getPaths().database) {
['piece-console', 'cat-jc'],
['piece-effects', 'cat-reaction'],
['piece-quiz', 'cat-jc'],
['piece-pixels', 'cat-reaction']
['piece-pixels', 'cat-reaction'],
['piece-inbox-link', 'cat-reaction'],
['piece-archive-demo', 'cat-reaction']
])
addCategory.run(...values);
@@ -173,7 +217,47 @@ export function seed(databasePath = getPaths().database) {
95,
0
],
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0]
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0],
[
'source-inbox-showcase',
'piece-inbox-link',
'Showcase-Aufzeichnung',
'https://example.com/showcase',
120,
0
]
]
);
replace(
'show_templates',
['id', 'name', 'description'],
[
['template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'],
['template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.']
]
);
replace(
'show_template_sections',
['id', 'template_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[
['template-default-start', 'template-default', 'Start', 'cat-jc', 0, 300],
[
'template-default-jc-reaction',
'template-default',
'JC / Reaction',
'cat-reaction',
1,
null
],
['template-default-game-1', 'template-default', 'Spiel 1', 'cat-game', 2, 3600],
['template-default-jc', 'template-default', 'JC', 'cat-jc', 3, null],
['template-default-game-2', 'template-default', 'Spiel 2', 'cat-game', 4, 3600],
['template-games-start', 'template-games', 'Start', 'cat-jc', 0, 300],
['template-games-game-1', 'template-games', 'Spiel 1', 'cat-game', 1, 2700],
['template-games-break', 'template-games', 'Pause & Chat', 'cat-jc', 2, 600],
['template-games-game-2', 'template-games', 'Spiel 2', 'cat-game', 3, 2700],
['template-games-game-3', 'template-games', 'Spiel 3', 'cat-game', 4, 2700]
]
);
@@ -186,6 +270,7 @@ export function seed(databasePath = getPaths().database) {
'twitch_title',
'announcement_text',
'status',
'revision',
'created_at_utc',
'updated_at_utc'
],
@@ -197,6 +282,18 @@ export function seed(databasePath = getPaths().database) {
'Kleine Welten, große Ideen | heinzlive',
'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.',
'ready',
1,
timestamp,
timestamp
],
[
'show-games-evening',
'Spieleabend: Kurbeln & Pixel',
'2026-10-16T18:00:00.000Z',
'Kurbeln, Pixel und ein langer Spieleabend | heinzlive',
'Heute spielen wir uns von ungewöhnlicher Hardware bis zu moderner Pixelart.',
'draft',
1,
timestamp,
timestamp
]
@@ -205,11 +302,25 @@ export function seed(databasePath = getPaths().database) {
replace(
'show_sections',
['id', 'show_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300],
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null],
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600]
'id',
'show_id',
'name',
'category_id',
'position',
'explicit_duration_seconds',
'is_reserve_bank'
],
[
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300, 0],
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null, 0],
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600, 0],
['reserve-bank-show-october', 'show-october', 'Reservebank', null, 3, null, 1],
['games-start', 'show-games-evening', 'Start', 'cat-jc', 0, 300, 0],
['games-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0],
['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 0],
['games-second', 'show-games-evening', 'Spiel 2', 'cat-game', 3, 2700, 0],
['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1]
]
);
@@ -224,9 +335,11 @@ export function seed(databasePath = getPaths().database) {
'duration_seconds',
'title_snapshot',
'intro_snapshot',
'research_snapshot',
'moderation_snapshot',
'transition_snapshot',
'presentation_snapshot',
'own_notes',
'is_reserve'
],
[
@@ -235,13 +348,15 @@ 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?',
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.',
'Von der kleinen Konsole geht es zur ganz großen Leinwand.',
'Weniger Leistung, mehr Idee.',
'',
0
],
[
@@ -253,27 +368,85 @@ export function seed(databasePath = getPaths().database) {
720,
'Warum praktische Effekte wieder auffallen',
'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.',
'',
'Beispiele vergleichen, nicht analog gegen digital ausspielen.',
'Nach so viel Handarbeit wird es spielerisch.',
'Wenn das Licht wirklich auf ein Modell fällt.',
'',
0
],
[
'usage-reserve',
'section-stories',
'reserve-bank-show-october',
'piece-pixels',
2,
0,
'open',
540,
'Pixelart, die nicht nostalgisch sein will',
'Pixel sind längst keine technische Notlösung mehr.',
'',
'Großaufnahmen vorbereiten, drei Stilmittel benennen.',
'Genug Theorie, jetzt spielen wir selbst.',
'Pixel als bewusste Entscheidung.',
'Ersatz, falls ein Spiel technisch ausfällt.',
1
],
[
'usage-games-console',
'games-first',
'piece-console',
0,
'open',
480,
'Die kleine Konsole mit dem großen Dreh',
'Wir beginnen mit Hardware, die ihr Spielprinzip gleich mitbringt.',
'Kurbelmechanik und verfügbare Spiele prüfen.',
'Konsole zeigen und Publikum nach Lieblingsmechaniken fragen.',
'Danach wechseln wir von Mechanik zu Grafik.',
'Eine Kurbel verändert, wie wir spielen.',
'Controller vor Sendungsbeginn laden.',
0
],
[
'usage-games-pixels',
'games-second',
'piece-pixels',
0,
'open',
540,
'Pixelart, die nicht nostalgisch sein will',
'Unser zweites Spiel nutzt Pixel nicht als Rückblick.',
'Drei aktuelle Beispiele vergleichen.',
'Auf Licht, Bewegung und Materialwirkung achten.',
'Zum Schluss sammeln wir eure Empfehlungen.',
'Pixel als bewusste Entscheidung.',
'',
0
]
]
);
database.prepare('DELETE FROM usage_source_snapshots').run();
database
.prepare(
`INSERT INTO usage_source_snapshots
(id, usage_id, label, url, timecode_seconds, position)
SELECT 'seed-source-' || u.id || '-' || ps.position, u.id, ps.label, ps.url,
ps.timecode_seconds, ps.position
FROM piece_usages u JOIN piece_sources ps ON ps.piece_id = u.original_piece_id`
)
.run();
database.prepare('DELETE FROM usage_topic_snapshots').run();
database
.prepare(
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
SELECT usage_id, name, position FROM (
SELECT u.id AS usage_id, t.name,
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
FROM piece_usages u JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
JOIN topics t ON t.id = pt.topic_id
)`
)
.run();
});
seedDatabase();
+5346 -231
View File
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -1,5 +1,15 @@
import type { AuthUser } from '#lib/server/auth.js';
declare global {
namespace App {}
namespace App {
interface Locals {
user: AuthUser | null;
}
interface PageData {
user?: AuthUser | null;
presentationMode?: boolean;
}
}
}
export {};
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
const publicPaths = new Set([
'/',
'/healthz',
'/login',
'/setup',
'/einladung',
'/passwort-vergessen',
'/passwort-zuruecksetzen'
]);
export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) {
redirect(303, hasUsers() ? '/login' : '/setup');
}
const response = await resolve(event);
response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
if (presentationPath || presentationTestPath) {
response.headers.set('Cache-Control', 'no-store');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Content-Security-Policy',
"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'"
);
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store');
}
return response;
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
</script>
<svg
width={size}
height={size}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
class="app-icon"
>
<use href={`/icons/classic-regular.svg#${name || 'circle'}`} />
</svg>
<style>
.app-icon {
display: inline-block;
flex: none;
fill: currentColor;
vertical-align: -0.125em;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import AppIcon from './AppIcon.svelte';
type Item = {
id: string;
name: string;
iconName?: string;
description?: string;
meta?: string;
};
let {
items,
selectedIds,
label,
fieldName,
onselectionchange
}: {
items: Item[];
selectedIds: string[];
label: string;
fieldName?: string;
onselectionchange: (ids: string[]) => void;
} = $props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
items.filter((item) =>
`${item.name} ${item.description ?? ''} ${item.meta ?? ''}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let selected = $derived(items.filter((item) => selectedIds.includes(item.id)));
function toggle(id: string) {
onselectionchange(
selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id]
);
}
</script>
<div class="choice-picker">
{#if fieldName}
{#each selectedIds as id (id)}<input type="hidden" name={fieldName} value={id} />{/each}
{/if}
{#if selected.length}
<div class="choice-picker-selected" aria-label={`${label}: ausgewählt`}>
{#each selected as item (item.id)}
<span
>{#if item.iconName}<AppIcon name={item.iconName} />{/if}{item.name}</span
>
{/each}
</div>
{/if}
<details bind:this={menu} class="choice-picker-menu">
<summary>{selected.length ? `${label} ändern` : `${label} auswählen`}</summary>
<div class="choice-picker-popover">
<label class="choice-picker-search">
<span class="sr-only">{label} durchsuchen</span>
<AppIcon name="magnifying-glass" />
<input type="search" placeholder={`${label} durchsuchen`} bind:value={query} />
</label>
<div class="choice-picker-options">
{#each filtered as item (item.id)}
<label class:active={selectedIds.includes(item.id)}>
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onchange={() => toggle(item.id)}
/>
{#if item.iconName}<AppIcon name={item.iconName} size="1.2em" />{/if}
<span
><strong>{item.name}</strong>{#if item.description}<small>{item.description}</small
>{/if}</span
>
{#if item.meta}<small>{item.meta}</small>{/if}
</label>
{:else}<p>Keine Treffer.</p>{/each}
</div>
<button
class="choice-picker-close text-button"
type="button"
onclick={() => (menu.open = false)}>Auswahl schließen</button
>
</div>
</details>
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { classicRegularIcons } from '#lib/icons/classic-regular.js';
import AppIcon from './AppIcon.svelte';
let { name = $bindable(), inputName = 'iconName' }: { name: string; inputName?: string } =
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let visible = $derived(filtered.slice(0, 120));
</script>
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
><span class="sr-only">Icons durchsuchen</span><input
type="search"
placeholder="Icon suchen"
bind:value={query}
/></label
>
<div class="icon-picker-grid">
{#each visible as icon (icon.name)}
<button
type="button"
class:active={name === icon.name}
title={icon.label}
onclick={() => {
name = icon.name;
menu.open = false;
}}><AppIcon name={icon.name} /><span>{icon.label}</span></button
>
{/each}
</div>
{#if filtered.length > visible.length}<p class="icon-picker-hint">
{visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu
finden.
</p>{/if}
</div>
</details>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
value = $bindable(),
form,
name = 'title',
label = 'Titel',
oncommit
}: {
value: string;
form: string;
name?: string;
label?: string;
oncommit?: () => void;
} = $props();
let editing = $state(false);
let original = $state('');
let input = $state<HTMLInputElement>();
let trigger = $state<HTMLButtonElement>();
async function edit() {
original = value;
editing = true;
await tick();
input?.focus();
input?.select();
}
async function finish(commit: boolean) {
if (!editing) return;
if (!commit) value = original;
editing = false;
if (commit && value !== original) oncommit?.();
await tick();
trigger?.focus();
}
</script>
<div class="inline-title-editor">
<input type="hidden" {form} {name} {value} />
{#if editing}
<label>
<span class="sr-only">{label}</span>
<input
bind:this={input}
bind:value
maxlength="200"
required
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
void finish(false);
}
}}
onblur={() => void finish(true)}
/>
</label>
{:else}
<button bind:this={trigger} type="button" class="inline-title-trigger" onclick={edit}>
<span>{value}</span><AppIcon name="pen" size="0.9em" />
<span class="sr-only">{label} bearbeiten</span>
</button>
{/if}
</div>
@@ -0,0 +1,85 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
name,
label,
value = $bindable(),
rows = 4,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let textarea: HTMLTextAreaElement;
async function replaceSelection(before: string, after = before, fallback = 'Text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || fallback;
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
async function addList() {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || 'Stichpunkt';
const replacement = selected
.split('\n')
.map((line) => `- ${line}`)
.join('\n');
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + 2, start + replacement.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
</script>
<div class:private-field={privateField} class="markdown-editor-field">
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
><strong>B</strong></button
>
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button
>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
><AppIcon name="list-check" /> Liste</button
>
<button
type="button"
aria-label="Link"
title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
><AppIcon name="link" /> Link</button
>
</span>
</div>
<textarea
bind:this={textarea}
aria-label={label}
{name}
{rows}
{placeholder}
{form}
maxlength="50000"
bind:value></textarea>
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
let { html }: { html: string } = $props();
</script>
<!-- html is produced exclusively by the allowlist sanitizer in $lib/server/markdown.ts. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="markdown-preview">{@html html}</div>
+13 -24
View File
@@ -1,32 +1,21 @@
<script lang="ts">
import AppIcon from './AppIcon.svelte';
let { name }: { name: string } = $props();
const aliases: Record<string, string> = {
library: 'folder-open',
shows: 'calendar-days',
archive: 'box-archive',
settings: 'gear',
edit: 'pen'
};
let iconName = $derived(aliases[name] ?? name);
</script>
<svg viewBox="0 0 24 24" aria-hidden="true">
{#if name === 'inbox'}
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
{:else if name === 'library'}
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
{:else if name === 'shows'}
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
{:else if name === 'archive'}
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
{:else if name === 'settings'}
<circle cx="12" cy="12" r="3" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{/if}
</svg>
<AppIcon name={iconName} size="1.25rem" />
<style>
svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
:global(.app-icon) {
display: block;
}
</style>
@@ -0,0 +1,82 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
labelledby,
activeSection = $bindable(),
onrequestclose,
header,
children,
footer
}: {
labelledby: string;
activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void;
header: Snippet;
children: Snippet;
footer: Snippet;
} = $props();
let dialog: HTMLDialogElement;
let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 };
async function selectSection(section: typeof activeSection) {
scrollPositions[activeSection] = workspace.scrollTop;
activeSection = section;
await tick();
workspace.scrollTop = scrollPositions[section];
}
export function showModal() {
if (!dialog.open) dialog.showModal();
}
export function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
<header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div>
<button
class="dialog-close"
type="button"
aria-label="Editor schließen"
onclick={onrequestclose}><AppIcon name="xmark" /></button
>
</header>
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button
type="button"
class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button
>
<button
type="button"
class:active={activeSection === 'research'}
aria-current={activeSection === 'research' ? 'page' : undefined}
onclick={() => selectSection('research')}>Recherche & Quellen</button
>
<button
type="button"
class:active={activeSection === 'presentation'}
aria-current={activeSection === 'presentation' ? 'page' : undefined}
onclick={() => selectSection('presentation')}>Präsentation</button
>
</nav>
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer>
</dialog>
@@ -0,0 +1,36 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { SentPieceUsage } from '#lib/server/shows.js';
let { history }: { history: SentPieceUsage[] } = $props();
function berlinDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium'
}).format(new Date(value));
}
</script>
{#if history.length}
<div class="piece-usage-history">
<p>
<strong>Gesendet am {berlinDate(history[0].usedAtUtc)}</strong>
in <a href={resolve('/sendungen/[id]', { id: history[0].showId })}>{history[0].showTitle}</a>
{#if history.length > 1}<span>+ {history.length - 1} weitere</span>{/if}
</p>
{#if history.length > 1}
<details>
<summary>Vollständige Verwendungshistorie</summary>
<ul>
{#each history as usage (usage.usageId)}
<li>
<time datetime={usage.usedAtUtc}>{berlinDate(usage.usedAtUtc)}</time>
<a href={resolve('/sendungen/[id]', { id: usage.showId })}>{usage.showTitle}</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
let {
content,
imageSrc = ''
}: {
content: PresentationContent & {
sources?: { label: string; url: string }[];
images?: { id: string; altText: string }[];
};
imageSrc?: string;
} = $props();
let frame = $state<HTMLIFrameElement>();
let playerUnavailable = $state(false);
let imageUnavailable = $state(false);
onMount(() => {
const receive = (event: MessageEvent) => {
if (content.provider !== 'tiktok' || event.origin !== 'https://www.tiktok.com') return;
if (event.source !== frame?.contentWindow || typeof event.data !== 'object' || !event.data)
return;
const message = event.data as { type?: string; value?: unknown };
if (message.type === 'onPlayerReady' && content.source?.timecodeSeconds) {
frame.contentWindow?.postMessage(
{
type: 'seekTo',
value: content.source.timecodeSeconds,
'x-tiktok-player': true
},
'https://www.tiktok.com'
);
}
if (message.type === 'onPlayerError') playerUnavailable = true;
};
addEventListener('message', receive);
return () => removeEventListener('message', receive);
});
</script>
{#if content.mode === 'text'}
<article class="output-content output-text">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
<h1>{content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html content.html}</div>
{#if content.images?.length}<div class="output-images">
{#each content.images as image (image.id)}
<img src={`${imageSrc}/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if content.sources?.length}<footer class="output-sources">
<span>Quellen</span>
{#each content.sources as source, index (`${index}-${source.url}`)}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else if content.mode === 'link'}
<article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
</div>{/if}
<div class="output-link-copy">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
<h1>{content.previewTitle || content.title}</h1>
{#if content.description}<p>{content.description}</p>{/if}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</div>
</article>
{:else}
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
<header>
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
<h1>{content.previewTitle || content.title}</h1>
</header>
{#if content.embedUrl && !playerUnavailable}
<div class="output-player">
<iframe
bind:this={frame}
title={content.previewTitle || content.title}
src={content.embedUrl}
allow="encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
onerror={() => (playerUnavailable = true)}
></iframe>
</div>
{:else}
<section class="output-fallback">
<strong>Video kann nicht eingebettet werden</strong>
<p>Der Anbieter oder diese URL ist für die eingebettete Wiedergabe nicht verfügbar.</p>
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</section>
{/if}
{#if content.source}<footer class="output-video-source">
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={content.source.url} target="_blank" rel="noopener noreferrer">Original öffnen</a>
</footer>{/if}
</article>
{/if}
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark';
const preferences: Preference[] = ['system', 'light', 'dark'];
@@ -42,8 +43,10 @@
onclick={cycle}
aria-label={`Darstellung: ${labels[preference]}`}
>
<span class="theme-symbol" aria-hidden="true">
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'}
</span>
<span class="theme-symbol" aria-hidden="true"
><AppIcon
name={preference === 'system' ? 'display' : preference === 'light' ? 'sun' : 'moon'}
/></span
>
<span>{labels[preference]}</span>
</button>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
import IconPicker from './IconPicker.svelte';
type Format = { id: string; name: string };
type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
iconName: string;
isActive: boolean;
formatIds: string[];
};
let { formats }: { formats: Format[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let tool = $state<Tool | null>(null);
let name = $state('');
let startUrl = $state('');
let description = $state('');
let iconName = $state('wrench');
let isActive = $state(true);
let formatIds = $state<string[]>([]);
export async function showModal(selectedTool: Tool | null = null) {
tool = selectedTool;
name = selectedTool?.name ?? '';
startUrl = selectedTool?.startUrl ?? '';
description = selectedTool?.description ?? '';
iconName = selectedTool?.iconName || 'wrench';
isActive = selectedTool?.isActive ?? true;
formatIds = [...(selectedTool?.formatIds ?? [])];
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog tool-editor-dialog"
aria-labelledby="tool-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name={iconName || 'wrench'} /></span>
<div>
<p class="eyebrow">{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}</p>
<h2 id="tool-dialog-title">{tool?.name || 'Neues Werkzeug'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Werkzeugdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form
class="management-dialog-form"
method="POST"
action={tool ? '?/updateTool' : '?/createTool'}
>
{#if tool}<input type="hidden" name="toolId" value={tool.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Start-URL<input
bind:value={startUrl}
name="startUrl"
type="url"
maxlength="2000"
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea bind:value={description} name="description" rows="3" maxlength="2000"
></textarea></label
>
</div>
<div class="management-dialog-selections">
<div>
<span class="field-label">Icon</span>
<IconPicker bind:name={iconName} />
</div>
<div>
<span class="field-label">Passende Formate</span>
<ChoicePicker
items={formats}
selectedIds={formatIds}
label="Formate"
fieldName="formatIds"
onselectionchange={(ids) => (formatIds = ids)}
/>
</div>
<label class="toggle-row"
><input type="checkbox" name="isActive" value="1" bind:checked={isActive} /><span
><strong>Aktiv</strong><small
>Deaktivierte Werkzeuge bleiben in bestehenden Pieces erhalten.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{tool ? 'Änderungen speichern' : 'Werkzeug anlegen'}</button
>
</footer>
</form>
</dialog>
+406
View File
@@ -0,0 +1,406 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
usage,
revision,
sectionId,
sections,
error,
enhanceCommand
}: {
usage: PieceUsage;
revision: number;
sectionId: string;
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
} = $props();
let formId = $derived(`usage-editor-form-${usage.id}`);
let editor: { showModal: () => void; close: () => void };
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let draft = $state(
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false;
editor.close();
}
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
if (finish) await finish(result);
if (result.result.type === 'success') dirty = false;
};
};
</script>
<button
class="usage-edit-button"
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => editor.showModal()}
>
<NavIcon name="edit" />
</button>
<PieceEditorDialog
bind:this={editor}
bind:activeSection
labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose}
>
{#snippet header()}
<p class="eyebrow">Sendungsfassung</p>
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet}
<form
id={formId}
use:enhance={enhanceEditor}
method="POST"
action="?/updateUsage"
class="piece-dialog-form"
oninput={() => (dirty = true)}
onchange={() => (dirty = true)}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
/>
<MarkdownEditorField
name="moderation"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
name="transition"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transition}
/>
</div>
<details class="piece-meta-details">
<summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid">
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
</div>
</details>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h3>Recherche & Quellen</h3>
</div>
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="research"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.research}
/>
<MarkdownEditorField
name="ownNotes"
label="Interne Notizen zu dieser Verwendung"
placeholder="Absprachen und Hinweise nur für diese Sendung"
privateField
bind:value={draft.ownNotes}
/>
</div>
<section class="piece-source-summary">
<p class="eyebrow">Snapshot-Quellen</p>
{#if draft.sources.length}
{#each draft.sources as source (source.id)}<div>
<strong>{source.label}</strong><small>{source.url}</small>
</div>{/each}
{:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
</section>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Snapshot</p>
<h3>Werkzeuge</h3>
</div>
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
</div>
{#if draft.tools.length}<div class="source-list">
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
<input type="hidden" name="usageToolId" value={tool.id} />
<div class="tool-assignment-name">
<strong>{tool.name}</strong>{#if tool.startUrl}<small>{tool.startUrl}</small>{/if}
</div>
<label
>Arbeitslink<input
name="toolWorkUrl"
type="url"
maxlength="2000"
placeholder={tool.startUrl || 'https://…'}
bind:value={tool.workUrl}
/></label
>
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
>
</div>{/each}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
<h3>Präsentation</h3>
</div>
<small>Private Recherche und Notizen werden nicht ausgegeben.</small>
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
<input
type="hidden"
name="presentationSourceLabel"
value={draft.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Kartentitel<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}></textarea></label
>
</div>
{:else}
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
{#if draft.media.length}<fieldset class="usage-media-picker">
<legend>Hochgeladenes Präsentationsbild</legend>
<label
><input
type="radio"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
dirty = true;
}}
/> Kein Bild</label
>
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
type="radio"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
dirty = true;
}}
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
>Auswählen</span
></label
>{/each}
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{/if}
</aside>
</div>
</section>
</form>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button" type="submit" disabled={dirty}>Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" />
<label
><span class="sr-only">Ziel</span><select name="sectionId"
><option value="">Reservebank</option>{#each sections as target (target.id)}<option
value={target.id}
selected={target.id === sectionId}>{target.name}</option
>{/each}</select
></label
>
<button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>
</form>
</div>
<div class="piece-dialog-primary-action">
{#if error}<span class="problem" role="alert">{error}</span>{/if}
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
</div>
{/snippet}
</PieceEditorDialog>
File diff suppressed because it is too large. Load diff
+255
View File
@@ -0,0 +1,255 @@
import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Cookies } from '@sveltejs/kit';
const temporaryDirectories: string[] = [];
let directory: string;
let databasePath: string;
function testCookies(): Cookies {
const values = new Map<string, string>();
return {
get: (name: string) => values.get(name),
getAll: () => Array.from(values, ([name, value]) => ({ name, value })),
set: (name: string, value: string) => {
values.set(name, value);
},
delete: (name: string) => {
values.delete(name);
},
serialize: () => ''
} as unknown as Cookies;
}
function tokenFromLatestMail(): string {
const outbox = path.join(directory, 'outbox');
const files = readdirSync(outbox).sort();
const message = JSON.parse(readFileSync(path.join(outbox, files.at(-1)!), 'utf8')) as {
text: string;
};
const match = message.text.match(/[?&]token=([^\s]+)/);
if (!match) throw new Error('Testmail enthält keinen Token.');
return decodeURIComponent(match[1]);
}
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-auth-test-'));
temporaryDirectories.push(directory);
databasePath = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_DB_PATH = databasePath;
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
process.env.SENDEWERK_MAIL_OUTBOX_DIR = path.join(directory, 'outbox');
process.env.SENDEWERK_MAIL_TRANSPORT = 'local';
process.env.SENDEWERK_APP_ORIGIN = 'http://localhost:5173';
process.env.SENDEWERK_SETUP_TOKEN = 'a'.repeat(32);
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(async () => {
const { closeDatabase } = await import('./database.js');
closeDatabase();
for (const name of [
'SENDEWERK_DB_PATH',
'SENDEWERK_UPLOAD_DIR',
'SENDEWERK_MAIL_OUTBOX_DIR',
'SENDEWERK_MAIL_TRANSPORT',
'SENDEWERK_MAIL_FROM',
'SENDEWERK_SMTP_HOST',
'SENDEWERK_APP_ORIGIN',
'SENDEWERK_SETUP_TOKEN'
]) {
delete process.env[name];
}
for (const temporaryDirectory of temporaryDirectories.splice(0)) {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
describe('authentication', () => {
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort';
const user = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'Bar@example.org',
displayName: 'Erster Barkeeper',
password
});
const database = new Database(databasePath, { readonly: true });
const row = database.prepare('SELECT * FROM users').get() as {
role: string;
email_normalized: string;
password_hash: string;
};
database.close();
expect(user.role).toBe('barkeeper');
expect(row.email_normalized).toBe('bar@example.org');
expect(row.password_hash).not.toContain(password);
await expect(
createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'second@example.org',
displayName: 'Zweiter Admin',
password
})
).rejects.toThrow('SETUP_CLOSED');
});
it('accepts an invitation only once and rejects an expired invitation', async () => {
const { acceptInvitation, createFirstAdmin, inviteUser } = await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
await inviteUser(admin, 'crew@example.org', 'thekencrew');
const firstToken = tokenFromLatestMail();
const crew = await acceptInvitation({
token: firstToken,
displayName: 'Crew',
password: 'Crew-Passwort-1234'
});
expect(crew.role).toBe('thekencrew');
await expect(
acceptInvitation({
token: firstToken,
displayName: 'Crew',
password: 'Crew-Passwort-1234'
})
).rejects.toThrow('TOKEN_INVALID');
await inviteUser(admin, 'late@example.org', 'thekencrew');
const expiredToken = tokenFromLatestMail();
const database = new Database(databasePath);
database.prepare("UPDATE invitations SET expires_at_utc = '2000-01-01T00:00:00.000Z'").run();
database.close();
await expect(
acceptInvitation({
token: expiredToken,
displayName: 'Zu spät',
password: 'Crew-Passwort-1234'
})
).rejects.toThrow('TOKEN_INVALID');
});
it('revokes active sessions immediately when a user is locked', async () => {
const { createFirstAdmin, createSession, getSessionUser, setUserLocked, SESSION_COOKIE } =
await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
const database = new Database(databasePath);
const crewId = crypto.randomUUID();
database
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
SELECT ?, 'crew@example.org', 'crew@example.org', 'Crew', password_hash, 'thekencrew',
password_changed_at_utc, created_at_utc, updated_at_utc FROM users WHERE id = ?`
)
.run(crewId, admin.id);
database.close();
const cookies = testCookies();
createSession(crewId, cookies);
const token = cookies.get(SESSION_COOKIE);
expect(getSessionUser(token)?.id).toBe(crewId);
setUserLocked(admin, crewId, true);
expect(getSessionUser(token)).toBeNull();
});
it('uses password reset links once and invalidates prior sessions', async () => {
const {
authenticate,
createFirstAdmin,
createSession,
getSessionUser,
requestPasswordReset,
resetPassword,
SESSION_COOKIE
} = await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Altes-Passwort-1234'
});
const cookies = testCookies();
createSession(admin.id, cookies);
const sessionToken = cookies.get(SESSION_COOKIE);
await requestPasswordReset(admin.email);
const expiredToken = tokenFromLatestMail();
const database = new Database(databasePath);
database
.prepare("UPDATE password_reset_tokens SET expires_at_utc = '2000-01-01T00:00:00.000Z'")
.run();
database.close();
await expect(resetPassword(expiredToken, 'Neues-Passwort-1234')).rejects.toThrow(
'TOKEN_INVALID'
);
await requestPasswordReset(admin.email);
const resetToken = tokenFromLatestMail();
await resetPassword(resetToken, 'Neues-Passwort-1234');
expect(getSessionUser(sessionToken)).toBeNull();
expect(await authenticate(admin.email, 'Altes-Passwort-1234')).toBeNull();
expect(await authenticate(admin.email, 'Neues-Passwort-1234')).not.toBeNull();
await expect(resetPassword(resetToken, 'Noch-ein-Passwort-1234')).rejects.toThrow(
'TOKEN_INVALID'
);
});
it('rate limits repeated failed login attempts without locking the account', async () => {
const { authenticate, createFirstAdmin, loginIsRateLimited, reserveLoginAttempt } =
await import('./auth.js');
await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
for (let attempt = 0; attempt < 5; attempt += 1) {
expect(reserveLoginAttempt('admin@example.org', '127.0.0.1')).toBe(true);
expect(await authenticate('admin@example.org', 'falsch')).toBeNull();
}
expect(loginIsRateLimited('admin@example.org', '127.0.0.1')).toBe(true);
const database = new Database(databasePath, { readonly: true });
const user = database.prepare('SELECT locked_at_utc FROM users').get() as {
locked_at_utc: string | null;
};
database.close();
expect(user.locked_at_utc).toBeNull();
});
it('denies administrative operations to Thekencrew', async () => {
const { requireBarkeeper } = await import('./auth.js');
expect(() =>
requireBarkeeper({
id: 'crew',
email: 'crew@example.org',
displayName: 'Crew',
role: 'thekencrew',
lockedAtUtc: null
})
).toThrow();
});
it('never falls back to local delivery when SMTP was selected', async () => {
process.env.SENDEWERK_MAIL_TRANSPORT = 'smtp';
process.env.SENDEWERK_MAIL_FROM = '';
process.env.SENDEWERK_SMTP_HOST = '';
const { sendMail } = await import('./mail.js');
await expect(
sendMail({ to: 'test@example.org', subject: 'Test', text: 'Keine echte Nachricht' })
).rejects.toThrow('SMTP-Konfiguration');
});
});
+610
View File
@@ -0,0 +1,610 @@
import { hash, verify } from '@node-rs/argon2';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { error } from '@sveltejs/kit';
import type { Cookies } from '@sveltejs/kit';
import { getAuthConfig } from './config.js';
import { getDatabase } from './database.js';
import { sendMail } from './mail.js';
export const SESSION_COOKIE = 'sendewerk_session';
export const INVITATION_COOKIE = 'sendewerk_invitation';
export const RESET_COOKIE = 'sendewerk_password_reset';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
const LINK_DURATION_MS = 24 * 60 * 60 * 1000;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const EMAIL_ATTEMPT_LIMIT = 5;
const ADDRESS_ATTEMPT_LIMIT = 20;
const RESET_WINDOW_MS = 60 * 60 * 1000;
const RESET_EMAIL_LIMIT = 3;
const RESET_ADDRESS_LIMIT = 10;
const PASSWORD_OPTIONS = {
algorithm: 2,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
outputLen: 32
} as const;
export type Role = 'barkeeper' | 'thekencrew';
export type AuthUser = {
id: string;
email: string;
displayName: string;
role: Role;
lockedAtUtc: string | null;
};
type UserRow = {
id: string;
email: string;
display_name: string;
password_hash: string;
role: Role;
locked_at_utc: string | null;
};
type InvitationRow = {
id: string;
email: string;
role: Role;
expires_at_utc: string;
accepted_at_utc: string | null;
revoked_at_utc: string | null;
};
function toUser(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
displayName: row.display_name,
role: row.role,
lockedAtUtc: row.locked_at_utc
};
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validDisplayName(name: string): boolean {
return name.trim().length >= 2 && name.trim().length <= 80;
}
export function validPassword(password: string): boolean {
return password.length >= 12 && Buffer.byteLength(password, 'utf8') <= 256;
}
export function isRole(value: string): value is Role {
return value === 'barkeeper' || value === 'thekencrew';
}
export async function hashPassword(password: string): Promise<string> {
return hash(password, PASSWORD_OPTIONS);
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password, PASSWORD_OPTIONS);
}
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string {
return randomBytes(32).toString('base64url');
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function nowUtc(): string {
return new Date().toISOString();
}
function futureUtc(durationMs: number): string {
return new Date(Date.now() + durationMs).toISOString();
}
export function hasUsers(): boolean {
const row = getDatabase().prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
return row.count > 0;
}
export function setupIsConfigured(): boolean {
return getAuthConfig().setupToken.length >= 32;
}
function setupTokenMatches(candidate: string): boolean {
const configured = getAuthConfig().setupToken;
if (configured.length < 32 || candidate.length !== configured.length) return false;
return timingSafeEqual(Buffer.from(candidate), Buffer.from(configured));
}
export async function createFirstAdmin(input: {
setupToken: string;
email: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
if (!setupTokenMatches(input.setupToken)) throw new Error('SETUP_TOKEN_INVALID');
const email = input.email.trim();
const emailNormalized = normalizeEmail(email);
const displayName = input.displayName.trim();
if (!validEmail(email) || !validDisplayName(displayName) || !validPassword(input.password)) {
throw new Error('VALIDATION');
}
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const create = database.transaction(() => {
const count = database.prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
if (count.count !== 0) throw new Error('SETUP_CLOSED');
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, 'barkeeper', ?, ?, ?)`
)
.run(id, email, emailNormalized, displayName, passwordHash, timestamp, timestamp, timestamp);
return { id, email, displayName, role: 'barkeeper', lockedAtUtc: null } satisfies AuthUser;
});
return create();
}
function sessionCookieOptions(expires?: Date) {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: getAuthConfig().secureCookies,
expires
};
}
function linkCookieOptions() {
return {
path: '/',
httpOnly: true,
sameSite: 'strict' as const,
secure: getAuthConfig().secureCookies,
maxAge: 10 * 60
};
}
export function storeLinkToken(cookies: Cookies, name: string, token: string): void {
cookies.set(name, token, linkCookieOptions());
}
export function readLinkToken(cookies: Cookies, name: string): string {
return cookies.get(name) || '';
}
export function deleteLinkToken(cookies: Cookies, name: string): void {
cookies.delete(name, { ...linkCookieOptions(), maxAge: 0 });
}
export function createSession(userId: string, cookies: Cookies): void {
const token = createToken();
const expires = new Date(Date.now() + SESSION_DURATION_MS);
getDatabase()
.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at_utc, expires_at_utc) VALUES (?, ?, ?, ?)'
)
.run(hashToken(token), userId, nowUtc(), expires.toISOString());
cookies.set(SESSION_COOKIE, token, sessionCookieOptions(expires));
}
export function deleteSession(cookies: Cookies): void {
const token = cookies.get(SESSION_COOKIE);
if (token)
getDatabase().prepare('DELETE FROM sessions WHERE token_hash = ?').run(hashToken(token));
cookies.delete(SESSION_COOKIE, sessionCookieOptions());
}
export function getSessionUser(token: string | undefined): AuthUser | null {
if (!token) return null;
const database = getDatabase();
const row = database
.prepare(
`SELECT u.id, u.email, u.display_name, u.password_hash, u.role, u.locked_at_utc
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at_utc > ? AND u.locked_at_utc IS NULL`
)
.get(hashToken(token), nowUtc()) as UserRow | undefined;
if (!row) return null;
return toUser(row);
}
function rateKey(value: string): string {
return hashToken(value || 'unknown');
}
export function loginIsRateLimited(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - LOGIN_WINDOW_MS).toISOString();
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(normalizeEmail(email)), since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(address), since) as { count: number };
return emailCount.count >= EMAIL_ATTEMPT_LIMIT || addressCount.count >= ADDRESS_ATTEMPT_LIMIT;
}
export function reserveLoginAttempt(email: string, address: string): boolean {
const database = getDatabase();
const reserve = database.transaction(() => {
if (loginIsRateLimited(email, address)) return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), rateKey(normalizeEmail(email)), rateKey(address), nowUtc());
return true;
});
return reserve();
}
export async function authenticate(email: string, password: string): Promise<AuthUser | null> {
const database = getDatabase();
const normalized = normalizeEmail(email);
const row = database
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE email_normalized = ?`
)
.get(normalized) as UserRow | undefined;
const matches = row
? await verifyPassword(row.password_hash, password)
: await verifyPassword(await dummyPasswordHash, password);
if (!row || !matches || row.locked_at_utc) {
return null;
}
database.prepare('DELETE FROM login_attempts WHERE email_key = ?').run(rateKey(normalized));
return toUser(row);
}
export function requireUser(user: AuthUser | null): AuthUser {
if (!user) error(401, 'Anmeldung erforderlich');
return user;
}
export function requireBarkeeper(user: AuthUser | null): AuthUser {
const authenticated = requireUser(user);
if (authenticated.role !== 'barkeeper') error(403, 'Diese Aktion erfordert Barkeeper-Rechte.');
return authenticated;
}
function requireCurrentBarkeeper(actor: AuthUser): AuthUser {
const row = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE id = ? AND role = 'barkeeper' AND locked_at_utc IS NULL`
)
.get(actor.id) as UserRow | undefined;
if (!row) error(403, 'Diese Aktion erfordert aktive Barkeeper-Rechte.');
return toUser(row);
}
export function listUsers(): AuthUser[] {
const rows = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users ORDER BY display_name COLLATE NOCASE`
)
.all() as UserRow[];
return rows.map(toUser);
}
export function listOpenInvitations() {
return getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc
FROM invitations
WHERE accepted_at_utc IS NULL AND revoked_at_utc IS NULL AND expires_at_utc > ?
ORDER BY created_at_utc DESC`
)
.all(nowUtc()) as Array<{ id: string; email: string; role: Role; expires_at_utc: string }>;
}
export async function inviteUser(
actor: AuthUser,
emailValue: string,
role: Role
): Promise<'local' | 'smtp'> {
actor = requireCurrentBarkeeper(actor);
const email = emailValue.trim();
const normalized = normalizeEmail(email);
if (!validEmail(email) || !isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
if (database.prepare('SELECT 1 FROM users WHERE email_normalized = ?').get(normalized)) {
throw new Error('USER_EXISTS');
}
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
const expiresAt = futureUtc(LINK_DURATION_MS);
const store = database.transaction(() => {
database
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE email_normalized = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(timestamp, normalized);
database
.prepare(
`INSERT INTO invitations (
id, email, email_normalized, role, token_hash, created_by_user_id,
created_at_utc, expires_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(id, email, normalized, role, hashToken(token), actor.id, timestamp, expiresAt);
});
store();
const link = new URL(
`/einladung?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
return await sendMail({
to: email,
subject: 'Einladung zu Sendewerk',
text: `Du wurdest zu Sendewerk eingeladen. Der Link ist 24 Stunden gültig und einmalig nutzbar:\n\n${link}`
});
} catch (mailError) {
database.prepare('UPDATE invitations SET revoked_at_utc = ? WHERE id = ?').run(nowUtc(), id);
throw mailError;
}
}
export function getInvitation(token: string): InvitationRow | null {
if (!token || token.length > 100) return null;
return (
(getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE token_hash = ?`
)
.get(hashToken(token)) as InvitationRow | undefined) ?? null
);
}
export async function acceptInvitation(input: {
token: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
const invitation = getInvitation(input.token);
const displayName = input.displayName.trim();
if (
!invitation ||
invitation.accepted_at_utc ||
invitation.revoked_at_utc ||
invitation.expires_at_utc <= nowUtc()
) {
throw new Error('TOKEN_INVALID');
}
if (!validDisplayName(displayName) || !validPassword(input.password))
throw new Error('VALIDATION');
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const accept = database.transaction(() => {
const current = database
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE id = ?`
)
.get(invitation.id) as InvitationRow;
if (current.accepted_at_utc || current.revoked_at_utc || current.expires_at_utc <= nowUtc()) {
throw new Error('TOKEN_INVALID');
}
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
current.email,
normalizeEmail(current.email),
displayName,
passwordHash,
current.role,
timestamp,
timestamp,
timestamp
);
database
.prepare('UPDATE invitations SET accepted_at_utc = ? WHERE id = ?')
.run(timestamp, current.id);
return {
id,
email: current.email,
displayName,
role: current.role,
lockedAtUtc: null
} satisfies AuthUser;
});
return accept();
}
export function reservePasswordResetRequest(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - RESET_WINDOW_MS).toISOString();
const emailKey = rateKey(`reset-email:${normalizeEmail(email)}`);
const addressKey = rateKey(`reset-address:${address}`);
const reserve = database.transaction(() => {
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(emailKey, since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(addressKey, since) as { count: number };
if (emailCount.count >= RESET_EMAIL_LIMIT || addressCount.count >= RESET_ADDRESS_LIMIT)
return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), emailKey, addressKey, nowUtc());
return true;
});
return reserve();
}
export async function requestPasswordReset(emailValue: string): Promise<void> {
const normalized = normalizeEmail(emailValue);
const database = getDatabase();
const row = database
.prepare('SELECT id, email FROM users WHERE email_normalized = ?')
.get(normalized) as { id: string; email: string } | undefined;
if (!row) return;
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO password_reset_tokens
(id, user_id, token_hash, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, row.id, hashToken(token), timestamp, futureUtc(LINK_DURATION_MS));
const link = new URL(
`/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
await sendMail({
to: row.email,
subject: 'Sendewerk-Passwort zurücksetzen',
text: `Mit diesem 24 Stunden gültigen, einmaligen Link kannst du dein Passwort zurücksetzen:\n\n${link}`
});
} catch (mailError) {
database.prepare('DELETE FROM password_reset_tokens WHERE id = ?').run(id);
throw mailError;
}
}
export function passwordResetIsValid(token: string): boolean {
if (!token || token.length > 100) return false;
return Boolean(
getDatabase()
.prepare(
`SELECT 1 FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc())
);
}
export async function resetPassword(token: string, password: string): Promise<void> {
if (!passwordResetIsValid(token)) throw new Error('TOKEN_INVALID');
if (!validPassword(password)) throw new Error('VALIDATION');
const passwordHash = await hashPassword(password);
const database = getDatabase();
const reset = database.transaction(() => {
const row = database
.prepare(
`SELECT id, user_id FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc()) as { id: string; user_id: string } | undefined;
if (!row) throw new Error('TOKEN_INVALID');
const timestamp = nowUtc();
database
.prepare(
`UPDATE users SET password_hash = ?, password_changed_at_utc = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(passwordHash, timestamp, timestamp, row.user_id);
database
.prepare(
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
)
.run(timestamp, row.user_id);
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
});
reset();
}
function activeBarkeeperCount(): number {
const row = getDatabase()
.prepare(
"SELECT count(*) AS count FROM users WHERE role = 'barkeeper' AND locked_at_utc IS NULL"
)
.get() as { count: number };
return row.count;
}
export function setUserLocked(actor: AuthUser, userId: string, locked: boolean): void {
actor = requireCurrentBarkeeper(actor);
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (locked && actor.id === userId) throw new Error('SELF_LOCK');
if (locked && row.role === 'barkeeper' && !row.locked_at_utc && activeBarkeeperCount() <= 1) {
throw new Error('LAST_BARKEEPER');
}
const update = database.transaction(() => {
database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.run(locked ? nowUtc() : null, nowUtc(), userId);
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
});
update();
}
export function setUserRole(actor: AuthUser, userId: string, role: Role): void {
requireCurrentBarkeeper(actor);
if (!isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (
row.role === 'barkeeper' &&
role === 'thekencrew' &&
!row.locked_at_utc &&
activeBarkeeperCount() <= 1
) {
throw new Error('LAST_BARKEEPER');
}
database
.prepare('UPDATE users SET role = ?, updated_at_utc = ? WHERE id = ?')
.run(role, nowUtc(), userId);
}
export function revokeInvitation(actor: AuthUser, invitationId: string): void {
requireCurrentBarkeeper(actor);
getDatabase()
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE id = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(nowUtc(), invitationId);
}
+33 -1
View File
@@ -7,6 +7,38 @@ function configuredPath(value: string | undefined, fallback: string): string {
export function getServerPaths() {
return {
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads')
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads'),
mailOutbox: configuredPath(process.env.SENDEWERK_MAIL_OUTBOX_DIR, '.data/mail-outbox')
};
}
export function getAuthConfig() {
const configuredOrigin = process.env.SENDEWERK_APP_ORIGIN || process.env.CLOUDRON_APP_ORIGIN;
if (process.env.NODE_ENV === 'production' && !configuredOrigin) {
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
}
const origin = new URL(configuredOrigin || 'http://localhost:5173');
return {
origin,
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
secureCookies: origin.protocol === 'https:'
};
}
export function getMailConfig() {
const transport = process.env.SENDEWERK_MAIL_TRANSPORT || 'local';
if (transport !== 'local' && transport !== 'smtp') {
throw new Error('SENDEWERK_MAIL_TRANSPORT muss local oder smtp sein.');
}
return {
transport,
from:
process.env.SENDEWERK_MAIL_FROM ||
(transport === 'local' ? 'Sendewerk <sendewerk@localhost>' : ''),
host: process.env.SENDEWERK_SMTP_HOST || '',
port: Number(process.env.SENDEWERK_SMTP_PORT || '587'),
secure: process.env.SENDEWERK_SMTP_SECURE === 'true',
user: process.env.SENDEWERK_SMTP_USER || '',
password: process.env.SENDEWERK_SMTP_PASSWORD || ''
};
}
+373
View File
@@ -0,0 +1,373 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createPiece,
createTaxonomy,
deletePieceImage,
deleteTaxonomy,
duplicatePiece,
getMediaFile,
getPiece,
getTaxonomy,
listPieceTemplates,
listPieces,
loadPiecePresentationMetadata,
savePiece,
savePieceAsTemplate,
setPieceArchived,
setPieceDeleted,
setPieceFavorite,
uploadPieceImage,
updateTaxonomy,
type PieceInput
} from './content.js';
import { closeDatabase, getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-content-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return {
title: 'Getestetes Piece',
outputTitle: '',
summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk',
status: 'ready',
durationSeconds: 420,
intro: '**Starker Einstieg**',
researchText: 'Private Recherche',
moderationNotes: '- Punkt eins',
transitionText: 'Weiter geht es.',
presentationText: '## Sichtbare Aussage',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
presentationDescription: '',
presentationImageUrl: '',
presentationSiteName: '',
presentationProvider: '',
presentationProviderId: '',
presentationTitleManual: false,
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction'],
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
],
...overrides
};
}
describe('content management', () => {
it('sorts global favorites first without changing their revision', () => {
const before = getPiece('piece-console')!;
setPieceFavorite('piece-console', true);
const pieces = listPieces({ statuses: ['ready'] });
expect(pieces[0]).toMatchObject({ id: 'piece-console', isFavorite: true });
expect(getPiece('piece-console')).toMatchObject({
isFavorite: true,
revision: before.revision,
status: before.status
});
});
it('persists independently versioned templates and copies their complete snapshot', () => {
const sourceId = createPiece('talk');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext'
})
);
const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand');
const template = listPieceTemplates().find((entry) => entry.id === created.id)!;
expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 });
const changed = getPiece(sourceId)!;
savePiece(sourceId, changed.revision, {
...changed,
status: 'ready',
presentationText: 'Spätere Änderung'
});
const copyId = createPiece(created.id);
expect(getPiece(copyId)).toMatchObject({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext',
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]
});
const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1);
expect(updated.revision).toBe(2);
expect(() =>
savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1)
).toThrow('CONFLICT');
});
it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk');
const initial = getPiece(id)!;
const saved = savePiece(id, initial.revision, completeInput());
const piece = getPiece(id)!;
expect(saved.revision).toBe(2);
expect(piece).toMatchObject({
title: 'Getestetes Piece',
status: 'ready',
researchText: 'Private Recherche',
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction']
});
expect(piece.sources).toEqual([
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]);
expect(() =>
savePiece(id, initial.revision, completeInput({ title: 'Veralteter Titel', topicIds: [] }))
).toThrow('CONFLICT');
expect(getPiece(id)).toMatchObject({
title: 'Getestetes Piece',
topicIds: ['topic-gaming', 'topic-internet']
});
});
it('stores fetched preview metadata and preserves manual corrections on reload', async () => {
const id = createPiece('find');
const initial = getPiece(id)!;
const saved = savePiece(
id,
initial.revision,
completeInput({
presentationMode: 'link',
presentationSourcePosition: 0,
presentationTitle: 'Manueller Titel',
presentationTitleManual: true
})
);
const loaded = await loadPiecePresentationMetadata(id, saved.revision, async () => ({
title: 'Automatischer Titel',
description: 'Geladener Anreißer',
imageUrl: 'https://example.com/vorschau.jpg',
siteName: 'Example',
provider: '',
providerId: ''
}));
expect(getPiece(id)).toMatchObject({
revision: loaded.revision,
presentationTitle: 'Manueller Titel',
presentationDescription: 'Geladener Anreißer',
presentationImageUrl: 'https://example.com/vorschau.jpg',
presentationSiteName: 'Example'
});
await loadPiecePresentationMetadata(id, loaded.revision, async () => ({
title: 'Anderer automatischer Titel',
description: 'Aktualisierter Anreißer',
imageUrl: '',
siteName: 'Example',
provider: '',
providerId: ''
}));
expect(getPiece(id)).toMatchObject({
presentationTitle: 'Manueller Titel',
presentationDescription: 'Aktualisierter Anreißer'
});
});
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte', 'microchip');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
expect(topic.iconName).toBe('microchip');
updateTaxonomy('topic', topic.id, 'Computergeschichte', 'microchip');
expect(getTaxonomy().topics.find((entry) => entry.id === topic.id)).toMatchObject({
name: 'Computergeschichte',
iconName: 'microchip'
});
expect(() => updateTaxonomy('topic', topic.id, 'Computergeschichte', '../unsafe')).toThrow(
'INVALID_ICON'
);
const sourceId = createPiece('find');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Seltene Hardware',
formatId: 'format-find',
topicIds: [topic.id],
categoryIds: ['cat-reaction']
})
);
expect(
listPieces({ statuses: ['ready'], query: 'dokumentation', topicId: topic.id })
).toHaveLength(1);
expect(listPieces({ statuses: ['ready'], query: '%' })).toHaveLength(0);
expect(() => deleteTaxonomy('topic', topic.id)).toThrow('TAXONOMY_IN_USE');
createTaxonomy('topic', 'Unbenutzt');
const unused = getTaxonomy().topics.find((entry) => entry.name === 'Unbenutzt')!;
deleteTaxonomy('topic', unused.id);
expect(getTaxonomy().topics.some((entry) => entry.id === unused.id)).toBe(false);
expect(() => deleteTaxonomy('format', 'format-find')).toThrow('TAXONOMY_IN_USE');
const duplicateId = await duplicatePiece(sourceId);
expect(getPiece(duplicateId)).toMatchObject({
title: 'Kopie von Seltene Hardware',
status: 'preparing',
topicIds: [topic.id]
});
const current = getPiece(sourceId)!;
setPieceArchived(sourceId, current.revision, true);
expect(listPieces({ statuses: ['ready'] }).some((piece) => piece.id === sourceId)).toBe(false);
const archived = listPieces({ statuses: ['archived'] }).find((piece) => piece.id === sourceId)!;
setPieceArchived(sourceId, archived.revision, false);
expect(getPiece(sourceId)?.status).toBe('ready');
});
it('sanitizes dangerous markdown while preserving useful formatting', () => {
const html = renderMarkdown(
'# Verbotene Ebene\n\n**sicher** <script>globalThis.compromised = true</script> [Link](javascript:alert(1))'
);
expect(html).toContain('<strong>sicher</strong>');
expect(html).not.toContain('<script');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('<h1');
});
it('accepts signed raster images, rejects disguised files and keeps storage private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const uploaded = await uploadPieceImage(
id,
1,
new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel',
true
);
const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
expect(piece.presentationMediaId).toBe(uploaded.id);
const media = await getMediaFile(piece.media[0].id);
expect(media?.data.equals(png)).toBe(true);
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
await expect(
uploadPieceImage(
id,
piece.revision,
new File(['<script>alert(1)</script>'], 'angriff.png', { type: 'image/png' }),
''
)
).rejects.toThrow('INVALID_IMAGE_TYPE');
const stored = getDatabase()
.prepare('SELECT storage_name FROM piece_media WHERE id = ?')
.get(piece.media[0].id) as { storage_name: string };
await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0);
expect(getPiece(id)?.presentationMediaId).toBeNull();
expect(await getMediaFile(piece.media[0].id)).toBeNull();
expect(existsSync(path.join(directory, 'uploads', stored.storage_name))).toBe(true);
});
it('selects presentation media atomically, copies active media and omits media from templates', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const selected = await uploadPieceImage(
id,
1,
new File([png], 'auswahl.png', { type: 'image/png' }),
'Auswahl',
true
);
const second = await uploadPieceImage(
id,
selected.revision,
new File([png], 'entfernt.png', { type: 'image/png' }),
'Entfernt'
);
await deletePieceImage(id, second.id, second.revision);
const source = getPiece(id)!;
expect(() =>
savePiece(id, source.revision, {
...source,
title: 'Darf nicht gespeichert werden',
status: 'preparing',
presentationMediaId: 'fremdes-medium'
})
).toThrow('presentation media does not belong to piece');
expect(getPiece(id)).toMatchObject({
title: 'Neues Piece',
revision: source.revision,
presentationMediaId: selected.id
});
const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id);
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
.get(template.id) as { snapshot_json: string };
expect(JSON.parse(snapshot.snapshot_json)).not.toHaveProperty('presentationMediaId');
expect(getPiece(createPiece(template.id))?.presentationMediaId).toBeNull();
});
it('soft-deletes pieces behind revisions and keeps their original media private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const uploaded = await uploadPieceImage(
id,
1,
new File([png], 'privat.png', { type: 'image/png' }),
'Privat'
);
const deleted = setPieceDeleted(id, uploaded.revision, true);
expect(getPiece(id)).toBeNull();
expect(listPieces({ statuses: ['preparing'] }).some((piece) => piece.id === id)).toBe(false);
expect(await getMediaFile(uploaded.id)).toBeNull();
expect(() => setPieceFavorite(id, true)).toThrow('NOT_FOUND');
expect(() => savePiece(id, deleted.revision, completeInput())).toThrow('NOT_FOUND');
expect(() => setPieceDeleted(id, uploaded.revision, false)).toThrow('CONFLICT');
const restored = setPieceDeleted(id, deleted.revision, false);
expect(getPiece(id)).toMatchObject({ id, revision: restored.revision });
expect((await getMediaFile(uploaded.id))?.data.equals(png)).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
+19 -3
View File
@@ -47,12 +47,28 @@ describe('database setup', () => {
(SELECT count(*) FROM schema_migrations) AS migrations,
(SELECT count(*) FROM pieces) AS pieces,
(SELECT count(*) FROM shows) AS shows,
(SELECT count(*) FROM piece_usages) AS usages`
(SELECT count(*) FROM piece_usages) AS usages,
(SELECT count(*) FROM show_templates) AS templates,
(SELECT count(*) FROM show_sections WHERE is_reserve_bank = 1) AS reserve_banks`
)
.get() as { migrations: number; pieces: number; shows: number; usages: number };
.get() as {
migrations: number;
pieces: number;
shows: number;
usages: number;
templates: number;
reserve_banks: number;
};
database.close();
expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 });
expect(counts).toEqual({
migrations: 11,
pieces: 6,
shows: 2,
usages: 5,
templates: 2,
reserve_banks: 2
});
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
});
+299
View File
@@ -0,0 +1,299 @@
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,
clearPresentationChannel,
getPresentationChannelView,
getLiveView,
getPresentationMedia,
getPresentationView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentationChannelAccess,
presentUsage,
rotatePresentationChannelKey,
selectUsage,
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('persists a frozen section countdown across usage changes and reloads', () => {
getDatabase()
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
)
.run();
const start = new Date('2026-10-23T18:00:00.000Z');
startOrResumeLive('show-october', 'timer-start', 0, start);
let live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
sectionId: 'section-stories',
plannedDurationSeconds: 300,
elapsedSeconds: 100
});
getDatabase()
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
)
.run();
completeCurrent(
'show-october',
'timer-next-piece',
live.liveRevision,
new Date('2026-10-23T18:02:00.000Z')
);
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
expect(live.current?.id).toBe('usage-effects');
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 300,
elapsedSeconds: 240
});
completeCurrent(
'show-october',
'timer-finish',
live.liveRevision,
new Date('2026-10-23T18:06:00.000Z')
);
expect(
getLiveView('show-october', new Date('2026-10-23T18:07:00.000Z')).sectionTimer
).toBeNull();
});
it('selects any rundown usage by stable id, including the first and completed entries', () => {
let live = getLiveView('show-october');
expect(() =>
selectUsage('show-october', 'usage-effects', 'select-before-start', live.liveRevision)
).toThrow('NOT_STARTED');
startOrResumeLive('show-october', 'start-selection', live.liveRevision);
live = getLiveView('show-october');
selectUsage('show-october', 'usage-effects', 'select-second', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-effects');
selectUsage('show-october', 'usage-console', 'select-first', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-console');
completeCurrent('show-october', 'complete-first', live.liveRevision);
live = getLiveView('show-october');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')?.status
).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console',
status: 'current'
});
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({
revision: 1_000_000_000,
content: null
});
const replacement = rotatePresentationChannelKey('show-october');
expect(replacement).not.toBe(key);
expect(presentationChannelAccess('default', key)).toBe(false);
expect(presentationChannelAccess('default', replacement)).toBe(true);
startOrResumeLive('show-october', 'start-channel', 0);
presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel();
expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull();
});
it('restores live state, records actual intervals and applies commands only once', () => {
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'
);
getDatabase()
.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES ('private-live-tool', 'usage-console', NULL, 'Privates Werkzeug', '',
'https://private.example.test/board', 'PRIVATE_TOOL_NOTE', 0)`
)
.run();
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',
topics: ['Gaming'],
images: [],
sources: []
});
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');
expect(serialized).not.toContain('https://example.com/console');
expect(serialized).not.toContain('https://private.example.test/board');
expect(serialized).not.toContain('PRIVATE_TOOL_NOTE');
const media = await getPresentationMedia(token, 'presentation-image');
expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october');
expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
});
it('projects only snapshotted link and video data and changes players explicitly', () => {
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'video',
presentation_source_label_snapshot = 'YouTube',
presentation_source_url_snapshot = 'https://youtu.be/dQw4w9WgXcQ',
presentation_source_timecode_snapshot = 75,
presentation_title_meta_snapshot = 'Videotitel',
presentation_site_name_snapshot = 'YouTube',
presentation_provider_snapshot = 'youtube',
presentation_provider_id_snapshot = 'dQw4w9WgXcQ'
WHERE id = 'usage-console'`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'link',
presentation_source_label_snapshot = 'Artikel',
presentation_source_url_snapshot = 'https://example.com/artikel',
presentation_title_meta_snapshot = 'Linktitel',
presentation_description_snapshot = 'Öffentlicher Anreißer',
presentation_site_name_snapshot = 'Example'
WHERE id = 'usage-effects'`
)
.run();
const token = issuePresentationAccess('show-october');
presentUsage('show-october', 'usage-console', 'present-video', 0);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'video',
previewTitle: 'Videotitel',
embedUrl: 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75',
source: { url: 'https://youtu.be/dQw4w9WgXcQ', timecodeSeconds: 75 }
});
presentUsage('show-october', 'usage-effects', 'present-link', 1);
const link = getPresentationView(token)!;
expect(link.content).toMatchObject({
mode: 'link',
previewTitle: 'Linktitel',
description: 'Öffentlicher Anreißer',
embedUrl: '',
source: { label: 'Artikel', url: 'https://example.com/artikel' }
});
const serialized = JSON.stringify(link);
expect(serialized).not.toContain('Herstellerangaben');
expect(serialized).not.toContain('Moderationsstichpunkte');
});
});
+842
View File
@@ -0,0 +1,842 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type Database from 'better-sqlite3';
import { getServerPaths } from './config.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.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;
sectionTimer: null | {
sectionId: string;
sectionName: string;
plannedDurationSeconds: number;
elapsedSeconds: number;
runningSinceUtc: string | null;
};
};
export type PresentationView = {
revision: number;
content: null | {
mode: PresentationContent['mode'];
topics: string[];
title: string;
html: string;
source: PresentationContent['source'];
previewTitle: string;
description: string;
imageUrl: string;
siteName: string;
provider: PresentationContent['provider'];
providerId: string;
embedUrl: string;
sources: { label: string; url: string; timecodeSeconds: number | null }[];
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 closeSectionTimer(database: Database.Database, showId: string, now: string) {
const timer = database
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId) as { id: string } | undefined;
if (!timer) return;
database
.prepare(
'UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id = ? AND ended_at_utc IS NULL'
)
.run(now, timer.id);
database
.prepare('UPDATE live_section_timers SET ended_at_utc = ? WHERE id = ?')
.run(now, timer.id);
}
function syncSectionTimer(
database: Database.Database,
showId: string,
usageId: string,
now: string
) {
const section = database
.prepare(
`SELECT s.id, s.explicit_duration_seconds 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(usageId, showId) as { id: string; explicit_duration_seconds: number | null } | undefined;
if (!section) throw new Error('NOT_FOUND');
const active = database
.prepare(
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL'
)
.get(showId) as { id: string; section_id: string } | undefined;
if (active?.section_id === section.id) return;
if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const timerId = randomUUID();
database
.prepare(
`INSERT INTO live_section_timers
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timerId, now);
}
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);
syncSectionTimer(database, showId, usageId, now);
}
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));
const timer = database
.prepare(
`SELECT t.id, t.section_id, s.name AS section_name, t.planned_duration_seconds
FROM live_section_timers t JOIN show_sections s ON s.id = t.section_id
WHERE t.show_id = ? AND t.ended_at_utc IS NULL`
)
.get(showId) as
| { id: string; section_id: string; section_name: string; planned_duration_seconds: number }
| undefined;
let sectionTimer: LiveView['sectionTimer'] = null;
if (timer) {
const runs = database
.prepare(
`SELECT started_at_utc, ended_at_utc FROM live_section_timer_runs
WHERE timer_id = ? ORDER BY started_at_utc`
)
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
const elapsedSeconds = runs.reduce(
(total, run) =>
total +
Math.max(
0,
Math.floor(
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
1_000
)
),
0
);
sectionTimer = {
sectionId: timer.section_id,
sectionName: timer.section_name,
plannedDurationSeconds: timer.planned_duration_seconds,
elapsedSeconds,
runningSinceUtc: running?.started_at_utc ?? 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,
sectionTimer
};
}
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 {
closeSectionTimer(database, showId, now);
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 selectUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'select',
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage(
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);
return database.transaction(() => {
let channel = database
.prepare("SELECT * FROM presentation_channels WHERE id = 'default'")
.get() as { access_key: string; show_id: string | null } | undefined;
if (!channel) {
const accessKey = randomBytes(32).toString('base64url');
clearPresentationState(database, showId);
database
.prepare(
`INSERT INTO presentation_channels
(id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc)
VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)`
)
.run(accessKey, hashToken(accessKey), showId, now, now);
channel = { access_key: accessKey, show_id: showId };
} else if (channel.show_id !== showId) {
clearPresentationState(database, showId);
database
.prepare(
`UPDATE presentation_channels SET show_id = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(showId, now);
}
return channel.access_key;
})();
}
function clearPresentationState(database: Database.Database, showId: string) {
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = NULL,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ? AND presentation_usage_id IS NOT NULL`
)
.run(nowIso(), showId);
}
export function clearPresentationChannel() {
const now = nowIso();
getDatabase().transaction(() => {
const database = getDatabase();
const channel = database
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
.get() as { show_id: string | null } | undefined;
if (!channel) throw new Error('NOT_FOUND');
if (channel.show_id) clearPresentationState(database, channel.show_id);
database
.prepare(
`UPDATE presentation_channels SET show_id = NULL, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(now);
})();
}
export function rotatePresentationChannelKey(showId: string) {
issuePresentationAccess(showId);
const accessKey = randomBytes(32).toString('base64url');
const now = nowIso();
getDatabase()
.prepare(
`UPDATE presentation_channels SET access_key = ?, key_hash = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(accessKey, hashToken(accessKey), now);
return accessKey;
}
export function presentationChannelAccess(channelId: string, accessKey: string) {
if (!channelId || !accessKey || accessKey.length > 200) return false;
return Boolean(
getDatabase()
.prepare('SELECT 1 FROM presentation_channels WHERE id = ? AND key_hash = ?')
.get(channelId, hashToken(accessKey))
);
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const channel = database
.prepare('SELECT show_id FROM presentation_channels WHERE key_hash = ?')
.get(hashToken(token)) as { show_id: string | null } | undefined;
if (channel) return channel.show_id;
const row = database
.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 getPresentationChannelView(channelId: string): PresentationView | null {
const channel = getDatabase()
.prepare('SELECT show_id, revision FROM presentation_channels WHERE id = ?')
.get(channelId) as { show_id: string | null; revision: number } | undefined;
if (!channel) return null;
if (!channel.show_id) return { revision: channel.revision * 1_000_000_000, content: null };
const view = getPresentationViewForShow(channel.show_id);
return { ...view, revision: channel.revision * 1_000_000_000 + view.revision };
}
export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
return getPresentationViewForShow(showId);
}
function getPresentationViewForShow(showId: string): PresentationView {
const database = getDatabase();
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.output_title_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
u.presentation_provider_snapshot, u.presentation_provider_id_snapshot
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;
output_title_snapshot: string;
presentation_snapshot: string;
presentation_mode_snapshot: PresentationContent['mode'];
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
presentation_title_meta_snapshot: string;
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: PresentationContent['provider'];
presentation_provider_id_snapshot: string;
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
topics: (
database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
.all(state.presentation_usage_id) as { name: string }[]
).map((topic) => topic.name),
title: usage.output_title_snapshot || usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot
? {
label: usage.presentation_source_label_snapshot,
url: usage.presentation_source_url_snapshot,
timecodeSeconds: usage.presentation_source_timecode_snapshot
}
: null,
previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot,
siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot,
embedUrl: buildEmbedUrl(
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot
),
sources: [],
images: (
database
.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
)
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text }))
}
};
}
export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null;
try {
return await fetchPresentationImage(view.content.imageUrl);
} catch {
return null;
}
}
export async function getPresentationMedia(
token: string,
snapshotId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId || !snapshotId) return null;
const media = database
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, ums.alt_text
FROM show_live_states state
JOIN usage_media_snapshots ums ON ums.usage_id = state.presentation_usage_id
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
)
.get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!media) return null;
try {
return {
data: await readFile(path.join(getServerPaths().uploads, media.storage_name)),
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text
};
} catch {
return null;
}
}
+49
View File
@@ -0,0 +1,49 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import nodemailer from 'nodemailer';
import { getMailConfig, getServerPaths } from './config.js';
export type MailMessage = {
to: string;
subject: string;
text: string;
};
export async function sendMail(message: MailMessage): Promise<'local' | 'smtp'> {
const config = getMailConfig();
if (config.transport === 'local') {
const outbox = getServerPaths().mailOutbox;
await mkdir(outbox, { recursive: true, mode: 0o700 });
const filename = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.json`;
await writeFile(
path.join(outbox, filename),
JSON.stringify({ from: config.from, ...message }, null, 2),
{ encoding: 'utf8', mode: 0o600 }
);
return 'local';
}
if (
!config.host ||
!config.from ||
!Number.isInteger(config.port) ||
config.port < 1 ||
config.port > 65_535 ||
Boolean(config.user) !== Boolean(config.password)
) {
throw new Error('Die SMTP-Konfiguration ist unvollständig.');
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
requireTLS: !config.secure,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
auth: config.user ? { user: config.user, pass: config.password } : undefined
});
await transporter.sendMail({ from: config.from, ...message });
return 'smtp';
}
+42
View File
@@ -0,0 +1,42 @@
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
export function renderMarkdown(markdown: string): string {
const rendered = marked.parse(markdown, {
async: false,
breaks: true,
gfm: true
}) as string;
return sanitizeHtml(rendered, {
allowedTags: [
'p',
'br',
'strong',
'em',
'del',
'blockquote',
'ul',
'ol',
'li',
'h2',
'h3',
'h4',
'code',
'pre',
'a',
'hr'
],
allowedAttributes: {
a: ['href', 'title', 'target', 'rel']
},
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false,
enforceHtmlBoundary: true,
transformTags: {
a: (_tagName, attributes) => ({
tagName: 'a',
attribs: { ...attributes, target: '_blank', rel: 'noopener noreferrer' }
})
}
});
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
buildEmbedUrl,
detectVideoProvider,
extractMetadataFromHtml,
fetchPresentationMetadata
} from './presentation.js';
describe('presentation metadata and providers', () => {
it('recognizes supported YouTube URL forms and creates privacy-enhanced embeds with start time', () => {
for (const url of [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://youtube.com/live/dQw4w9WgXcQ'
]) {
expect(detectVideoProvider(url)).toEqual({ provider: 'youtube', id: 'dQw4w9WgXcQ' });
}
expect(buildEmbedUrl('youtube', 'dQw4w9WgXcQ', 75)).toBe(
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
);
expect(detectVideoProvider('https://evil.example/watch?v=dQw4w9WgXcQ')).toBeNull();
expect(detectVideoProvider('https://youtube.com/watch?v=invalid')).toBeNull();
});
it('recognizes canonical TikTok videos without inventing a start parameter', () => {
expect(
detectVideoProvider('https://www.tiktok.com/@scout2015/video/6718335390845095173')
).toEqual({ provider: 'tiktok', id: '6718335390845095173' });
expect(buildEmbedUrl('tiktok', '6718335390845095173', 90)).toBe(
'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1'
);
});
it('extracts generic metadata from local HTML and tolerates a missing image', async () => {
const metadata = await extractMetadataFromHtml(
`<!doctype html><html><head>
<meta property="og:title" content="Lokale Vorschau">
<meta property="og:description" content="Ein kurzer Anreißer">
<meta property="og:site_name" content="Testseite">
</head></html>`,
'https://example.com/artikel'
);
expect(metadata).toMatchObject({
title: 'Lokale Vorschau',
description: 'Ein kurzer Anreißer',
imageUrl: '',
siteName: 'Testseite'
});
});
it('rejects malformed and private metadata targets before a network request', async () => {
await expect(fetchPresentationMetadata('keine-url')).rejects.toThrow('FETCH_INVALID_URL');
await expect(fetchPresentationMetadata('http://127.0.0.1/private')).rejects.toThrow(
'FETCH_BLOCKED'
);
await expect(fetchPresentationMetadata('http://[::1]/private')).rejects.toThrow(
'FETCH_BLOCKED'
);
});
});
+275
View File
@@ -0,0 +1,275 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import http from 'node:http';
import https from 'node:https';
import metascraper from 'metascraper';
import description from 'metascraper-description';
import image from 'metascraper-image';
import publisher from 'metascraper-publisher';
import title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = {
title: string;
description: string;
imageUrl: string;
siteName: string;
provider: VideoProvider | '';
providerId: string;
};
export type PresentationSource = {
label: string;
url: string;
timecodeSeconds: number | null;
};
export type PresentationContent = {
mode: PresentationMode;
topics: string[];
title: string;
html: string;
source: PresentationSource | null;
previewTitle: string;
description: string;
imageUrl: string;
siteName: string;
provider: VideoProvider | '';
providerId: string;
embedUrl: string;
};
const scrape = metascraper([title(), description({ truncateLength: 500 }), image(), publisher()]);
const MAX_HTML_BYTES = 1024 * 1024;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
function isPrivateIpv4(address: string) {
const parts = address.split('.').map(Number);
return (
parts[0] === 0 ||
parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 169 && parts[1] === 254) ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) ||
parts[0] >= 224
);
}
function isPrivateAddress(address: string) {
if (isIP(address) === 4) return isPrivateIpv4(address);
const normalized = address.toLowerCase().split('%')[0];
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8')) {
return true;
}
if (
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
) {
return true;
}
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? isPrivateIpv4(mapped) : false;
}
async function publicAddress(url: URL) {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('FETCH_BLOCKED');
}
if (url.port && url.port !== (url.protocol === 'https:' ? '443' : '80')) {
throw new Error('FETCH_BLOCKED');
}
const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
throw new Error('FETCH_BLOCKED');
}
const addresses = isIP(hostname)
? [{ address: hostname, family: isIP(hostname) }]
: await lookup(hostname, { all: true, verbatim: true }).catch(() => {
throw new Error('FETCH_UNAVAILABLE');
});
if (!addresses.length || addresses.some((entry) => isPrivateAddress(entry.address))) {
throw new Error('FETCH_BLOCKED');
}
return addresses[0];
}
async function requestPublic(
input: string,
accept: string,
maximumBytes: number,
redirects = 0
): Promise<{ url: string; contentType: string; data: Buffer }> {
let url: URL;
try {
url = new URL(input);
} catch {
throw new Error('FETCH_INVALID_URL');
}
const target = await publicAddress(url);
const client = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const request = client.get(
url,
{
headers: {
Accept: accept,
'User-Agent': 'Sendewerk/0.5.1 (+https://git.hnz.io/hnzio/sendewerk)'
},
lookup: (_hostname, _options, callback) =>
callback(null, target.address, target.family as 4 | 6)
},
(response) => {
const status = response.statusCode ?? 0;
if (status >= 300 && status < 400 && response.headers.location) {
response.resume();
if (redirects >= MAX_REDIRECTS) return reject(new Error('FETCH_REDIRECTS'));
const next = new URL(response.headers.location, url).href;
requestPublic(next, accept, maximumBytes, redirects + 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
response.resume();
return reject(new Error('FETCH_UNAVAILABLE'));
}
const chunks: Buffer[] = [];
let size = 0;
response.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > maximumBytes) {
request.destroy(new Error('FETCH_TOO_LARGE'));
return;
}
chunks.push(chunk);
});
response.on('end', () =>
resolve({
url: url.href,
contentType: String(response.headers['content-type'] ?? ''),
data: Buffer.concat(chunks)
})
);
}
);
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('FETCH_TIMEOUT')));
request.on('error', reject);
});
}
export function detectVideoProvider(input: string): {
provider: VideoProvider;
id: string;
} | null {
let url: URL;
try {
url = new URL(input);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
const host = url.hostname.toLowerCase();
let youtubeId = '';
if (host === 'youtu.be') youtubeId = url.pathname.split('/').filter(Boolean)[0] ?? '';
if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'm.youtube.com') {
if (url.pathname === '/watch') youtubeId = url.searchParams.get('v') ?? '';
else if (/^\/(shorts|embed|live)\//.test(url.pathname)) {
youtubeId = url.pathname.split('/').filter(Boolean)[1] ?? '';
}
}
if (/^[A-Za-z0-9_-]{11}$/.test(youtubeId)) {
return { provider: 'youtube', id: youtubeId };
}
if (host === 'tiktok.com' || host === 'www.tiktok.com' || host === 'm.tiktok.com') {
const id = url.pathname.match(/\/video\/(\d+)/)?.[1] ?? '';
if (/^\d+$/.test(id)) return { provider: 'tiktok', id };
}
return null;
}
export function buildEmbedUrl(
provider: VideoProvider | '',
providerId: string,
timecodeSeconds: number | null
) {
if (provider === 'youtube' && /^[A-Za-z0-9_-]{11}$/.test(providerId)) {
const url = new URL(`https://www.youtube-nocookie.com/embed/${providerId}`);
if (timecodeSeconds && timecodeSeconds > 0)
url.searchParams.set('start', String(timecodeSeconds));
return url.href;
}
if (provider === 'tiktok' && /^\d+$/.test(providerId)) {
const url = new URL(`https://www.tiktok.com/player/v1/${providerId}`);
url.searchParams.set('autoplay', '0');
url.searchParams.set('controls', '1');
return url.href;
}
return '';
}
export async function extractMetadataFromHtml(
html: string,
url: string
): Promise<PresentationMetadata> {
const metadata = await scrape({ html, url });
return {
title: String(metadata.title ?? '').slice(0, 300),
description: String(metadata.description ?? '').slice(0, 1_000),
imageUrl: String(metadata.image ?? '').slice(0, 2_000),
siteName: String(metadata.publisher ?? new URL(url).hostname).slice(0, 200),
provider: '',
providerId: ''
};
}
async function providerMetadata(url: string, provider: VideoProvider) {
const endpoint =
provider === 'youtube'
? `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url)}`
: `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
const response = await requestPublic(endpoint, 'application/json', 256 * 1024);
if (!response.contentType.toLowerCase().includes('json')) throw new Error('FETCH_INVALID_TYPE');
const value = JSON.parse(response.data.toString('utf8')) as Record<string, unknown>;
return {
title: String(value.title ?? '').slice(0, 300),
description: '',
imageUrl: String(value.thumbnail_url ?? '').slice(0, 2_000),
siteName: String(value.provider_name ?? (provider === 'youtube' ? 'YouTube' : 'TikTok')).slice(
0,
200
)
};
}
export async function fetchPresentationMetadata(url: string): Promise<PresentationMetadata> {
const video = detectVideoProvider(url);
if (video) {
const metadata = await providerMetadata(url, video.provider);
return { ...metadata, provider: video.provider, providerId: video.id };
}
const response = await requestPublic(url, 'text/html,application/xhtml+xml', MAX_HTML_BYTES);
const contentType = response.contentType.toLowerCase();
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
throw new Error('FETCH_INVALID_TYPE');
}
return extractMetadataFromHtml(response.data.toString('utf8'), response.url);
}
export async function fetchPresentationImage(url: string) {
const response = await requestPublic(
url,
'image/jpeg,image/png,image/webp,image/gif',
MAX_IMAGE_BYTES
);
const mimeType = response.contentType.split(';')[0].trim().toLowerCase();
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(mimeType)) {
throw new Error('FETCH_INVALID_TYPE');
}
return { data: response.data, mimeType };
}
+46 -88
View File
@@ -1,4 +1,5 @@
import { getDatabase } from './database.js';
import { getShow as getShowById, listShows } from './shows.js';
export type PreviewPiece = {
id: string;
@@ -85,7 +86,7 @@ export function getPieces(): PreviewPiece[] {
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status != 'archived'
WHERE p.status IN ('preparing', 'ready') AND p.deleted_at_utc IS NULL
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
)
.all() as PieceRow[];
@@ -104,96 +105,49 @@ export function getPieces(): PreviewPiece[] {
}));
}
type ShowRow = {
id: string;
title: string;
starts_at_utc: string;
twitch_title: string;
announcement_text: string;
status: string;
};
type SectionRow = {
id: string;
name: string;
category: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
};
type UsageRow = {
id: string;
show_section_id: string;
title_snapshot: string;
duration_seconds: number;
status: PreviewUsage['status'];
intro_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
is_reserve: number;
};
export function getShow(): PreviewShow {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status
FROM shows ORDER BY starts_at_utc DESC LIMIT 1`
)
.get() as ShowRow | undefined;
export function getShow(showId?: string): PreviewShow {
const latest = showId ? null : listShows().find((show) => show.startsAtUtc !== null);
const show = getShowById(showId ?? latest?.id ?? '');
if (!show) throw new Error('No preview show found. Run npm run db:seed.');
const sectionRows = database
.prepare(
`SELECT s.id, s.name, c.name AS category, c.color AS category_color,
s.explicit_duration_seconds
FROM show_sections s
LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
.all(show.id) as SectionRow[];
const usageRows = database
.prepare(
`SELECT id, show_section_id, title_snapshot, duration_seconds, status,
intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve
FROM piece_usages WHERE show_section_id IN (
SELECT id FROM show_sections WHERE show_id = ?
) ORDER BY position`
)
.all(show.id) as UsageRow[];
const sections = sectionRows.map((section) => {
const usages = usageRows
.filter((usage) => usage.show_section_id === section.id)
.map((usage) => ({
id: usage.id,
title: usage.title_snapshot,
durationSeconds: usage.duration_seconds,
status: usage.status,
intro: usage.intro_snapshot,
moderation: usage.moderation_snapshot,
transition: usage.transition_snapshot,
isReserve: usage.is_reserve === 1
}));
const plannedUsages = usages.filter((usage) => !usage.isReserve);
return {
const sections: PreviewSection[] = show.sections.map((section) => ({
id: section.id,
name: section.name,
category: section.category ?? 'Offen',
categoryColor: section.category_color ?? '#8b8f98',
durationSeconds:
section.explicit_duration_seconds ??
plannedUsages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
categoryColor: section.categoryColor ?? '#8b8f98',
durationSeconds: section.durationSeconds,
usages: section.usages.map((usage) => ({
id: usage.id,
title: usage.title,
durationSeconds: usage.durationSeconds,
status: usage.status,
intro: usage.intro,
moderation: usage.moderation,
transition: usage.transition,
isReserve: false
}))
}));
const reserveHost = sections[Math.min(1, sections.length - 1)];
if (reserveHost) {
reserveHost.usages.push(
...show.reserve.map((usage) => ({
id: usage.id,
title: usage.title,
durationSeconds: usage.durationSeconds,
status: usage.status,
intro: usage.intro,
moderation: usage.moderation,
transition: usage.transition,
isReserve: true
}))
);
}
return {
id: show.id,
title: show.title,
startsAtUtc: show.starts_at_utc,
startsAtBerlin: new Intl.DateTimeFormat('de-DE', {
startsAtUtc: show.startsAtUtc ?? '',
startsAtBerlin: show.startsAtUtc
? new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
weekday: 'short',
day: '2-digit',
@@ -201,12 +155,13 @@ export function getShow(): PreviewShow {
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(show.starts_at_utc)),
twitchTitle: show.twitch_title,
announcement: show.announcement_text,
}).format(new Date(show.startsAtUtc))
: 'Termin offen',
twitchTitle: show.twitchTitle,
announcement: show.announcementText,
status: show.status,
sections,
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
totalDurationSeconds: show.totalDurationSeconds
};
}
@@ -220,9 +175,12 @@ export function getTaxonomy() {
id: string;
name: string;
}[],
topics: database.prepare('SELECT id, name FROM topics ORDER BY name').all() as {
topics: database
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as {
id: string;
name: string;
iconName: string;
}[]
};
}
+758
View File
@@ -0,0 +1,758 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getPiece } from './content.js';
import { closeDatabase, getDatabase } from './database.js';
import {
addPieceUsage,
addShowSection,
createShow,
createAndAddPieceUsage,
duplicatePieceUsage,
getPieceUsageContexts,
getShow,
listShowTemplates,
movePieceUsage,
moveShowSection,
refreshPieceUsageFromOriginal,
removePieceUsage,
removeShowSection,
updatePieceUsage,
updateShowSection,
type PieceUsage
} from './shows.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shows-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
const edited = { ...usage, ...overrides };
return {
title: edited.title,
outputTitle: edited.outputTitle,
intro: edited.intro,
research: edited.research,
moderation: edited.moderation,
transition: edited.transition,
presentation: edited.presentation,
presentationMode: edited.presentationMode,
presentationSourceLabel: edited.presentationSourceLabel,
presentationSourceUrl: edited.presentationSourceUrl,
presentationSourceTimecodeSeconds: edited.presentationSourceTimecodeSeconds,
presentationTitle: edited.presentationTitle,
presentationDescription: edited.presentationDescription,
presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds
};
}
describe('show builder data layer', () => {
it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const before = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Direkt geplantes Piece',
templateId: 'talk',
sectionId: section.id
});
const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Darf nicht doppelt entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(repeated).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(1);
expect(getPiece(first.pieceId)).toMatchObject({
title: 'Direkt geplantes Piece',
status: 'ready',
formatId: 'format-talk'
});
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
expect(() =>
createAndAddPieceUsage(created.id, first.revision, 'failing-command', {
title: 'Rollback',
templateId: 'blank',
sectionId: 'missing'
})
).toThrow('NOT_FOUND');
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
const removed = removePieceUsage(created.id, first.usageId, first.revision);
const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', {
title: 'Darf auch nach dem Entfernen nicht neu entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(afterRemoval).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(0);
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
});
it('creates blank and templated shows with independent sections and reserve banks', () => {
const templatesBefore = listShowTemplates();
expect(
templatesBefore.find((template) => template.id === 'template-default')?.sections
).toHaveLength(5);
expect(templatesBefore.find((template) => template.id === 'template-games')?.name).toBe(
'Spieleabend'
);
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
expect(getShow(blank.id)).toMatchObject({
title: 'Neue Sendung',
startsAtUtc: null,
sections: [],
reserve: []
});
const reserveCount = getDatabase()
.prepare(
'SELECT count(*) AS count FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1'
)
.get(blank.id) as { count: number };
expect(reserveCount.count).toBe(1);
const templated = createShow({
title: 'Vorlagenkopie',
startsAtUtc: '2027-01-08T19:00:00Z',
templateId: 'template-default'
});
const copy = getShow(templated.id)!;
expect(copy.sections.map((section) => section.name)).toEqual([
'Start',
'JC / Reaction',
'Spiel 1',
'JC',
'Spiel 2'
]);
updateShowSection(templated.id, copy.sections[0].id, copy.revision, {
name: 'Eigener Start',
categoryId: 'cat-jc',
explicitDurationSeconds: 420
});
expect(listShowTemplates()).toEqual(templatesBefore);
});
it('snapshots a ready piece repeatedly and keeps usage edits independent from its original', () => {
const created = createShow({
title: 'Doppelte Verwendung',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Fundstücke',
categoryId: 'cat-reaction',
explicitDurationSeconds: null
});
const first = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const second = addPieceUsage(created.id, first.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const usages = getShow(created.id)!.sections[0].usages;
const firstSaved = updatePieceUsage(
created.id,
first.id,
second.revision,
editedUsage(usages[0], {
title: 'Konsole als Auftakt',
durationSeconds: 300,
ownNotes: 'Kurbel zuerst zeigen',
research: 'Eigene Recherchekopie'
})
);
updatePieceUsage(
created.id,
second.id,
firstSaved.revision,
editedUsage(usages[1], { title: 'Konsole als Rückblick', durationSeconds: 900 })
);
const show = getShow(created.id)!;
expect(show.sections[0].usages).toMatchObject([
{
title: 'Konsole als Auftakt',
durationSeconds: 300,
ownNotes: 'Kurbel zuerst zeigen',
research: 'Eigene Recherchekopie'
},
{ title: 'Konsole als Rückblick', durationSeconds: 900 }
]);
expect(getPiece('piece-console')).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
durationSeconds: 480,
researchText:
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten'
});
});
it('derives sent history and current planning only from qualifying usages by original id', () => {
const completed = createShow({
title: 'Abgeschlossene Sendung',
startsAtUtc: '2027-01-10T19:00:00Z',
templateId: null
});
const completedSection = addShowSection(completed.id, completed.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const done = addPieceUsage(completed.id, completedSection.revision, {
pieceId: 'piece-console',
sectionId: completedSection.id
});
const skipped = addPieceUsage(completed.id, done.revision, {
pieceId: 'piece-effects',
sectionId: completedSection.id
});
const reserve = addPieceUsage(completed.id, skipped.revision, {
pieceId: 'piece-pixels',
sectionId: null
});
getDatabase()
.prepare("UPDATE piece_usages SET status = 'done' WHERE id IN (?, ?)")
.run(done.id, reserve.id);
getDatabase()
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
.run(skipped.id);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id);
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
const activeSection = addShowSection(active.id, active.revision, {
name: 'Plan',
categoryId: null,
explicitDurationSeconds: null
});
const planned = addPieceUsage(active.id, activeSection.revision, {
pieceId: 'piece-console',
sectionId: activeSection.id
});
const plannedReserve = addPieceUsage(active.id, planned.revision, {
pieceId: 'piece-effects',
sectionId: null
});
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(planned.id);
let contexts = getPieceUsageContexts(
['piece-console', 'piece-effects', 'piece-pixels'],
active.id
);
expect(contexts['piece-console']).toMatchObject({
isPlannedInCurrentShow: true,
sentHistory: [
{
usageId: done.id,
showId: completed.id,
showTitle: 'Abgeschlossene Sendung',
usedAtUtc: '2027-01-10T19:00:00.000Z'
}
]
});
expect(contexts['piece-effects']).toEqual({
isPlannedInActiveShow: true,
isPlannedInCurrentShow: true,
sentHistory: []
});
expect(contexts['piece-pixels']).toEqual({
isPlannedInActiveShow: true,
isPlannedInCurrentShow: false,
sentHistory: []
});
removePieceUsage(active.id, plannedReserve.id, plannedReserve.revision);
contexts = getPieceUsageContexts(['piece-effects'], active.id);
expect(contexts['piece-effects'].isPlannedInCurrentShow).toBe(false);
});
it('duplicates an adjusted usage directly after its source without live measurements', () => {
const created = createShow({
title: 'Einplanungsduplikat',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const usage = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const source = getShow(created.id)!.sections[0].usages[0];
const adjusted = updatePieceUsage(
created.id,
usage.id,
usage.revision,
editedUsage(source, {
title: 'Angepasste Einplanung',
durationSeconds: 777,
intro: 'Eigene Einleitung',
research: 'Eigene Recherche',
moderation: 'Eigene Moderation',
transition: 'Eigene Überleitung',
presentation: 'Eigene Präsentation',
ownNotes: 'Eigene Notizen'
})
);
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
getDatabase()
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc, ended_at_utc)
VALUES ('source-run', ?, ?, '2027-01-01T10:00:00.000Z', '2027-01-01T10:10:00.000Z')`
)
.run(created.id, usage.id);
const pieceCountBefore = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const duplicate = duplicatePieceUsage(created.id, usage.id, adjusted.revision);
const usages = getShow(created.id)!.sections[0].usages;
expect(usages.map((item) => item.id)).toEqual([usage.id, duplicate.id]);
expect(usages[1]).toMatchObject({
originalPieceId: 'piece-console',
status: 'open',
durationSeconds: 777,
title: 'Angepasste Einplanung',
intro: 'Eigene Einleitung',
research: 'Eigene Recherche',
moderation: 'Eigene Moderation',
transition: 'Eigene Überleitung',
presentation: 'Eigene Präsentation',
ownNotes: 'Eigene Notizen'
});
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get(duplicate.id)
).toEqual({ count: 0 });
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(pieceCountBefore);
getDatabase()
.prepare(
`UPDATE pieces SET title = 'Aktuelles Original', duration_seconds = 420,
intro = 'Neue Einleitung', research_text = 'Neue Recherche',
moderation_notes = 'Neue Moderation', transition_text = 'Neue Überleitung',
presentation_text = 'Neue Präsentation' WHERE id = 'piece-console'`
)
.run();
const refreshed = refreshPieceUsageFromOriginal(created.id, usage.id, duplicate.revision);
const refreshedUsages = getShow(created.id)!.sections[0].usages;
expect(refreshedUsages[0]).toMatchObject({
title: 'Aktuelles Original',
durationSeconds: 420,
intro: 'Neue Einleitung',
research: 'Neue Recherche',
moderation: 'Neue Moderation',
transition: 'Neue Überleitung',
presentation: 'Neue Präsentation',
ownNotes: 'Eigene Notizen',
status: 'done'
});
expect(refreshedUsages[1]).toMatchObject({
title: 'Angepasste Einplanung',
durationSeconds: 777,
status: 'open'
});
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get(usage.id)
).toEqual({ count: 1 });
expect(() => duplicatePieceUsage(created.id, usage.id, adjusted.revision)).toThrow('CONFLICT');
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
getDatabase()
.prepare('UPDATE piece_usages SET original_piece_id = NULL WHERE id = ?')
.run(duplicate.id);
expect(() =>
refreshPieceUsageFromOriginal(created.id, duplicate.id, refreshed.revision)
).toThrow('ORIGINAL_NOT_FOUND');
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
expect(getShow(created.id)!.sections[0].usages).toHaveLength(2);
});
it('snapshots presentation settings until an explicit refresh and copies them on duplication', () => {
getDatabase()
.prepare(
`UPDATE piece_sources SET label = 'YouTube', url = ?, timecode_seconds = 75
WHERE piece_id = 'piece-console' AND position = 0`
)
.run('https://youtu.be/dQw4w9WgXcQ');
getDatabase()
.prepare(
`UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel',
presentation_mode = 'video', presentation_source_position = 0,
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
)
.run();
const show = createShow({ title: 'Snapshot-Test', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Videos',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
outputTitle: 'Öffentlicher Ausgabetitel',
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75,
presentationTitle: 'Gespeicherte Vorschau'
});
getDatabase()
.prepare(
`UPDATE pieces SET presentation_title = 'Neuer Originaltitel' WHERE id = 'piece-console'`
)
.run();
expect(getShow(show.id)!.sections[0].usages[0].presentationTitle).toBe('Gespeicherte Vorschau');
const refreshed = refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.presentationTitle).toBe('Neuer Originaltitel');
duplicatePieceUsage(show.id, usage.id, refreshed.revision);
expect(getShow(show.id)!.sections[0].usages[1]).toMatchObject({
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationTitle: 'Neuer Originaltitel',
status: 'open'
});
});
it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => {
const database = getDatabase();
database
.prepare(
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)`
)
.run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z');
database
.prepare(
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)`
)
.run();
const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Block',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
topics: [{ name: 'Gaming', iconName: '' }]
});
database
.prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'")
.run();
database
.prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'")
.run();
database
.prepare(
"UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'"
)
.run();
database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run();
database
.prepare(
"INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')"
)
.run();
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.sources[0].label).toBe('Herstellerseite');
expect(usage.tools[0]).toMatchObject({
name: 'Tierlist',
workUrl: 'https://example.com/board'
});
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: '' }]);
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.sources[0].label).toBe('Neue Quelle');
expect(usage.tools[0]).toMatchObject({
name: 'Neuer Katalogname',
workUrl: 'https://example.com/new'
});
expect(usage.topics).toEqual([{ name: 'Netzkultur', iconName: '' }]);
});
it('snapshots active media, their presentation choice and topic icons independently', () => {
const database = getDatabase();
database.prepare("UPDATE topics SET icon_name = 'gamepad' WHERE id = 'topic-gaming'").run();
const insertMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc, removed_at_utc)
VALUES (?, 'piece-console', ?, ?, 'image/png', 10, ?, ?, ?, ?)`
);
insertMedia.run(
'media-first',
'first.png',
'first-original.png',
'Erstes Bild',
0,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-second',
'second.png',
'second-original.png',
'Zweites Bild',
1,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-removed',
'removed.png',
'removed-original.png',
'Entferntes Bild',
2,
'2026-10-23T00:00:00.000Z',
'2026-10-23T01:00:00.000Z'
);
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-first')"
)
.run();
const show = createShow({ title: 'Medien', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Bilder',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: 'gamepad' }]);
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-first', altText: 'Erstes Bild', position: 0, isPresentation: true },
{
originalMediaId: 'media-second',
altText: 'Zweites Bild',
position: 1,
isPresentation: false
}
]);
expect(usage.presentationMediaId).toBe(usage.media[0].id);
const selected = updatePieceUsage(show.id, usage.id, added.revision, {
...editedUsage(usage),
presentationMediaId: usage.media[1].id
});
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media.map((item) => item.isPresentation)).toEqual([false, true]);
expect(() =>
updatePieceUsage(show.id, usage.id, selected.revision, {
...editedUsage(usage),
presentationMediaId: 'media-first'
})
).toThrow('INVALID_PRESENTATION_MEDIA');
expect(getShow(show.id)?.revision).toBe(selected.revision);
const duplicate = duplicatePieceUsage(show.id, usage.id, selected.revision);
const duplicateUsage = getShow(show.id)!.sections[0].usages[1];
expect(duplicateUsage.media.map((item) => item.originalMediaId)).toEqual([
'media-first',
'media-second'
]);
expect(duplicateUsage.presentationMediaId).toBe(duplicateUsage.media[1].id);
expect(duplicateUsage.media.map((item) => item.id)).not.toEqual(
usage.media.map((item) => item.id)
);
database
.prepare("UPDATE piece_media SET removed_at_utc = ? WHERE id = 'media-first'")
.run('2026-10-23T02:00:00.000Z');
database
.prepare("UPDATE piece_media SET alt_text = 'Neuer Alternativtext' WHERE id = 'media-second'")
.run();
database.prepare("DELETE FROM piece_presentation_media WHERE piece_id = 'piece-console'").run();
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-second')"
)
.run();
refreshPieceUsageFromOriginal(show.id, usage.id, duplicate.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-second', altText: 'Neuer Alternativtext', isPresentation: true }
]);
expect(duplicateUsage.media).toHaveLength(2);
});
it('persists normalized section and usage reordering, moving and removal', () => {
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
const firstSection = addShowSection(created.id, created.revision, {
name: 'Erster Block',
categoryId: null,
explicitDurationSeconds: null
});
const secondSection = addShowSection(created.id, firstSection.revision, {
name: 'Zweiter Block',
categoryId: null,
explicitDurationSeconds: null
});
const firstUsage = addPieceUsage(created.id, secondSection.revision, {
pieceId: 'piece-console',
sectionId: firstSection.id
});
const secondUsage = addPieceUsage(created.id, firstUsage.revision, {
pieceId: 'piece-effects',
sectionId: firstSection.id
});
const thirdUsage = addPieceUsage(created.id, secondUsage.revision, {
pieceId: 'piece-pixels',
sectionId: firstSection.id
});
const reordered = movePieceUsage(created.id, thirdUsage.id, thirdUsage.revision, {
sectionId: firstSection.id,
position: 0
});
const reserved = movePieceUsage(created.id, firstUsage.id, reordered.revision, {
sectionId: null,
position: 0
});
const plannedAgain = movePieceUsage(created.id, firstUsage.id, reserved.revision, {
sectionId: secondSection.id,
position: 0
});
const removedUsage = removePieceUsage(created.id, secondUsage.id, plannedAgain.revision);
const movedSection = moveShowSection(created.id, secondSection.id, 0, removedUsage.revision);
removeShowSection(created.id, firstSection.id, movedSection.revision);
const show = getShow(created.id)!;
expect(show.sections.map((section) => [section.name, section.position])).toEqual([
['Zweiter Block', 0]
]);
expect(show.sections[0].usages.map((usage) => [usage.id, usage.position])).toEqual([
[firstUsage.id, 0]
]);
expect(show.reserve).toEqual([]);
expect(getDatabase().pragma('foreign_key_check')).toEqual([]);
});
it('uses explicit section durations once and excludes the show-wide reserve', () => {
const created = createShow({ title: 'Dauer', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Fester Block',
categoryId: null,
explicitDurationSeconds: 600
});
const planned = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-effects',
sectionId: section.id
});
addPieceUsage(created.id, planned.revision, {
pieceId: 'piece-pixels',
sectionId: null
});
const explicit = getShow(created.id)!;
expect(explicit.sections[0].durationSeconds).toBe(600);
expect(explicit.totalDurationSeconds).toBe(600);
expect(explicit.reserve[0].durationSeconds).toBe(540);
updateShowSection(created.id, section.id, explicit.revision, {
name: 'Summierter Block',
categoryId: null,
explicitDurationSeconds: null
});
expect(getShow(created.id)).toMatchObject({
totalDurationSeconds: 720,
sections: [{ durationSeconds: 720 }]
});
});
it('rolls stale and invalid aggregate mutations back and rejects completed shows', () => {
const created = createShow({ title: 'Konflikt', startsAtUtc: null, templateId: null });
const saved = addShowSection(created.id, created.revision, {
name: 'Gültig',
categoryId: null,
explicitDurationSeconds: null
});
expect(() =>
addShowSection(created.id, created.revision, {
name: 'Veraltet',
categoryId: null,
explicitDurationSeconds: null
})
).toThrow('CONFLICT');
expect(getShow(created.id)).toMatchObject({
revision: saved.revision,
sections: [{ name: 'Gültig' }]
});
expect(() => removeShowSection(created.id, 'missing', saved.revision)).toThrow('NOT_FOUND');
expect(getShow(created.id)?.revision).toBe(saved.revision);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(created.id);
expect(() =>
addPieceUsage(created.id, saved.revision, {
pieceId: 'piece-console',
sectionId: saved.id
})
).toThrow('SHOW_COMPLETED');
expect(getShow(created.id)).toMatchObject({
revision: saved.revision,
sections: [{ usages: [] }]
});
});
});
File diff suppressed because it is too large. Load diff
+78
View File
@@ -0,0 +1,78 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase } from './database.js';
import { createTool, listTools, updateTool } from './tools.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-tools-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('tool catalog', () => {
it('validates, classifies and deactivates tools without deleting them', () => {
const id = createTool({
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true,
formatIds: ['format-quiz']
});
expect(listTools()).toEqual([
{
id,
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true,
formatIds: ['format-quiz']
}
]);
updateTool(id, {
name: 'Tierlist',
startUrl: '',
description: '',
iconName: 'list-ol',
isActive: false,
formatIds: []
});
expect(listTools()).toEqual([]);
expect(listTools(true)[0]).toMatchObject({ id, iconName: 'list-ol', isActive: false });
expect(() =>
createTool({
name: 'Unsicher',
startUrl: 'javascript:alert(1)',
description: '',
iconName: 'wrench',
isActive: true,
formatIds: []
})
).toThrow('INVALID_TOOL_URL');
expect(() =>
createTool({
name: 'Unbekanntes Icon',
startUrl: '',
description: '',
iconName: 'kein-font-awesome-icon',
isActive: true,
formatIds: []
})
).toThrow('INVALID_ICON');
});
});
+156
View File
@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js';
export type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
iconName: string;
isActive: boolean;
formatIds: string[];
};
export type ToolInput = Omit<Tool, 'id'>;
function clean(value: unknown, maximum: number, required = false) {
const text = String(value ?? '').trim();
if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL');
return text;
}
function validUrl(value: string) {
if (!value) return '';
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
throw new Error();
return url.href;
} catch {
throw new Error('INVALID_TOOL_URL');
}
}
function validated(input: ToolInput): ToolInput {
const formatIds = [...new Set(input.formatIds.filter(Boolean))];
if (formatIds.length > 30) throw new Error('INVALID_TOOL');
if (formatIds.length) {
const placeholders = formatIds.map(() => '?').join(', ');
const row = getDatabase()
.prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`)
.get(...formatIds) as { count: number };
if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT');
}
return {
name: clean(input.name, 120, true),
startUrl: validUrl(clean(input.startUrl, 2_000)),
description: clean(input.description, 2_000),
iconName: (() => {
const iconName = clean(input.iconName, 120, true);
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
})(),
isActive: Boolean(input.isActive),
formatIds
};
}
export function listTools(includeInactive = false): Tool[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT id, name, start_url, description, icon_name, is_active FROM tools
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
)
.all() as {
id: string;
name: string;
start_url: string;
description: string;
icon_name: string;
is_active: number;
}[];
const formats = database.prepare(
'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id'
);
return rows.map((row) => ({
id: row.id,
name: row.name,
startUrl: row.start_url,
description: row.description,
iconName: row.icon_name,
isActive: row.is_active === 1,
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
}));
}
function replaceFormats(toolId: string, formatIds: string[]) {
const database = getDatabase();
database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId);
const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)');
for (const formatId of formatIds) insert.run(toolId, formatId);
}
export function createTool(rawInput: ToolInput) {
const input = validated(rawInput);
const database = getDatabase();
const id = randomUUID();
const now = new Date().toISOString();
try {
database.transaction(() => {
database
.prepare(
`INSERT INTO tools (id, name, start_url, description, icon_name, is_active, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.name,
input.startUrl,
input.description,
input.iconName,
input.isActive ? 1 : 0,
now,
now
);
replaceFormats(id, input.formatIds);
})();
} catch (cause) {
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
throw new Error('TOOL_EXISTS', { cause });
}
throw cause;
}
return id;
}
export function updateTool(id: string, rawInput: ToolInput) {
const input = validated(rawInput);
const database = getDatabase();
try {
database.transaction(() => {
const result = database
.prepare(
`UPDATE tools SET name = ?, start_url = ?, description = ?, icon_name = ?, is_active = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(
input.name,
input.startUrl,
input.description,
input.iconName,
input.isActive ? 1 : 0,
new Date().toISOString(),
id
);
if (result.changes !== 1) throw new Error('NOT_FOUND');
replaceFormats(id, input.formatIds);
})();
} catch (cause) {
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
throw new Error('TOOL_EXISTS', { cause });
}
throw cause;
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'),
liveControllerMode: url.pathname === '/live'
});
+36 -2
View File
@@ -3,9 +3,10 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children } = $props();
let { children, data } = $props();
const primaryNavigation = [
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
@@ -13,7 +14,9 @@
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = [...primaryNavigation, administration];
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) {
return (
@@ -29,6 +32,11 @@
/>
</svelte:head>
{#if data.presentationMode}
{@render children()}
{:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user}
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
@@ -50,6 +58,7 @@
</nav>
<div class="sidebar-footer">
{#if data.user.role === 'barkeeper'}
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
@@ -60,6 +69,14 @@
<span>{administration.label}</span>
</a>
</nav>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
</form>
<ThemeToggle />
</div>
</aside>
@@ -69,6 +86,11 @@
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden"
><AppIcon name="right-from-bracket" /></button
>
</form>
<ThemeToggle />
</header>
@@ -87,3 +109,15 @@
{/each}
</nav>
</div>
{:else}
<div class="auth-shell">
<header class="auth-header">
<a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<ThemeToggle />
</header>
<main class="auth-main">{@render children()}</main>
</div>
{/if}
+4 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js';
export function load() {
redirect(307, '/fundus');
export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login');
}
+38
View File
@@ -0,0 +1,38 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return {
pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'],
deleted: true
})
};
};
export const actions = {
restore: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
return { success: 'Piece wurde in seinen vorherigen Bereich zurückgelegt.' };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
},
restoreDeleted: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
return { success: 'Gelöschtes Piece wurde wiederhergestellt.' };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
}
} satisfies Actions;
+49 -5
View File
@@ -1,13 +1,57 @@
<script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Archiv</h1>
<p class="page-intro">Abgeschlossene Sendungen in ihrem damaligen Stand.</p>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<h2>Noch keine Sendung archiviert</h2>
<p>Abgeschlossene Sendungen werden hier später dauerhaft auffindbar.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
</div>
<form method="POST" action="?/restore">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true"><AppIcon name="box-archive" /></div>
<h2>Keine Pieces archiviert</h2>
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section>{/if}
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy">
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
</p>
{#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
{#if piece.summary}<p>{piece.summary}</p>{/if}
</div>
<form method="POST" action="?/restoreDeleted">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Keine gelöschten Pieces.</p>{/if}
</section>
</div>
+40
View File
@@ -0,0 +1,40 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) };
};
export const actions = {
capture: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
return { captured: true, capturedId: id };
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
title: String(data.get('title') || ''),
url: String(data.get('url') || ''),
error:
code === 'INVALID_SOURCES'
? 'Bitte eine vollständige HTTP- oder HTTPS-Adresse eingeben.'
: 'Ein Titel ist erforderlich.'
});
}
},
delete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { deleted: true };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
}
}
} satisfies Actions;
+76 -6
View File
@@ -1,13 +1,83 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Eingang</h1>
<p class="page-intro">Neue Gedanken und Fundstücke vor der Übernahme in den Fundus.</p>
<p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<h2>Keine neuen Inhalte</h2>
<p>Hier erscheinen später neue Gedanken und ausgewählte Feed-Fundstücke.</p>
</section>
<section class="quiet-panel capture-panel">
<div>
<p class="eyebrow">Schnellaufnahme</p>
<h2>Was soll nicht verloren gehen?</h2>
</div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.captured}<div class="form-message success" role="status">
Im Eingang gespeichert.
<a href={resolve('/fundus/[id]', { id: form.capturedId })}>Jetzt ausarbeiten</a>
</div>{/if}
<form method="POST" action="?/capture" class="capture-form">
<label
>Gedanke <span>optional bei URL</span><input
name="title"
maxlength="200"
value={form?.title ?? ''}
placeholder="Kurze Idee festhalten"
/></label
>
<label
>Link <span>optional</span><input
name="url"
type="url"
maxlength="2000"
value={form?.url ?? ''}
placeholder="https://…"
/></label
>
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
{#each data.pieces as piece (piece.id)}<article class="inbox-item">
<a href={resolve('/fundus/[id]', { id: piece.id })}
><span
><strong>{piece.title}</strong><small
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
></span
><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a
>
<details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
>
<div class="piece-actions-popover">
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm(`„${piece.title}“ löschen?`) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form>
</div>
</details>
</article>{/each}
</section>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div>
<h2>Eingang ist leer</h2>
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div>
+57
View File
@@ -0,0 +1,57 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
acceptInvitation,
createSession,
deleteLinkToken,
getInvitation,
INVITATION_COOKIE,
readLinkToken,
storeLinkToken,
validDisplayName,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ url, cookies }) => {
const queryToken = url.searchParams.get('token');
if (queryToken) {
if (queryToken.length <= 100) storeLinkToken(cookies, INVITATION_COOKIE, queryToken);
else deleteLinkToken(cookies, INVITATION_COOKIE);
redirect(303, '/einladung');
}
const token = readLinkToken(cookies, INVITATION_COOKIE);
const invitation = getInvitation(token);
const valid = Boolean(
invitation &&
!invitation.accepted_at_utc &&
!invitation.revoked_at_utc &&
invitation.expires_at_utc > new Date().toISOString()
);
return { valid, email: valid ? invitation?.email : null };
};
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const token = readLinkToken(cookies, INVITATION_COOKIE);
const displayName = String(data.get('displayName') || '').trim();
const password = String(data.get('password') || '');
if (!validDisplayName(displayName) || !validPassword(password)) {
return fail(400, {
displayName,
error: 'Bitte Namen und ein Passwort mit mindestens 12 Zeichen prüfen.'
});
}
try {
const user = await acceptInvitation({ token, displayName, password });
deleteLinkToken(cookies, INVITATION_COOKIE);
createSession(user.id, cookies);
} catch {
return fail(400, {
displayName,
error: 'Die Einladung ist ungültig, abgelaufen oder bereits verwendet.'
});
}
redirect(303, '/fundus');
}
} satisfies Actions;
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Einladung annehmen | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Einladung</p>
<h1>Sendewerk-Konto aktivieren</h1>
{#if !data.valid && !form?.error}
<div class="form-message error" role="alert">
Die Einladung ist ungültig, abgelaufen oder bereits verwendet.
</div>
{:else}
<p>Die Einladung gilt für <strong>{data.email}</strong>.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Name<input
name="displayName"
required
maxlength="80"
value={form?.displayName ?? ''}
autocomplete="name"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<button class="primary-button" type="submit">Konto aktivieren</button>
</form>
{/if}
</section>
+120 -3
View File
@@ -1,5 +1,122 @@
import { getPieces } from '#lib/server/previews.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
createPiece,
deletePieceTemplate,
duplicatePiece,
getTaxonomy,
listPieces,
listPieceTemplates,
setPieceArchived,
setPieceDeleted,
setPieceFavorite
} from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js';
export function load() {
return { pieces: getPieces() };
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
};
const pieces = listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
templates: listPieceTemplates(),
filters
};
};
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.';
if (code === 'NOT_FOUND') return 'Das Piece wurde nicht gefunden.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
export const actions = {
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = createPiece(String(data.get('template') || 'blank'));
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
deletePieceTemplate(String(data.get('templateId') || ''), Number(data.get('revision')));
return { intent: 'deleteTemplate', success: 'Vorlage gelöscht.' };
} catch (cause) {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
}
},
duplicate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = await duplicatePiece(String(data.get('pieceId') || ''));
} catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
archive: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { intent: 'archive', success: 'Piece archiviert.' };
} catch (cause) {
return fail(409, { intent: 'archive', error: actionError(cause) });
}
},
favorite: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceFavorite(String(data.get('pieceId') || ''), data.get('favorite') === '1');
return { intent: 'favorite', success: 'Favorit aktualisiert.' };
} catch (cause) {
return fail(404, { intent: 'favorite', error: actionError(cause) });
}
},
delete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return {
intent: 'delete',
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
};
} catch (cause) {
return fail(409, { intent: 'delete', error: actionError(cause) });
}
}
} satisfies Actions;
+244 -55
View File
@@ -1,19 +1,50 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { enhance } from '$app/forms';
import { goto, replaceState } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import { formatClock } from '#lib/format.js';
import type { PageData } from './$types';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props();
let query = $state('');
let filter = $state<'all' | 'ready' | 'preparing'>('all');
let filteredPieces = $derived(
data.pieces.filter((piece) => {
const text =
`${piece.title} ${piece.summary} ${piece.format} ${piece.topics.join(' ')}`.toLowerCase();
return text.includes(query.toLowerCase()) && (filter === 'all' || piece.status === filter);
})
let { data, form }: PageProps = $props();
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
(showPlannedPieces || !piece.isPlannedInActiveShow)
)
);
onMount(() => {
const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams;
showUsedPieces = search.get('includeUsed') === '1';
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
const url = new URL(page.url.href);
if (checked) url.searchParams.set(name, '1');
else url.searchParams.delete(name);
// This URL is cloned from the current page; only same-route filter parameters change.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
}
async function resetFilters() {
await goto(resolve('/fundus'), { invalidateAll: true });
showUsedPieces = false;
showPlannedPieces = false;
}
const statusLabel = {
ready: 'Sendefertig',
preparing: 'In Vorbereitung',
@@ -30,73 +61,231 @@
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
<div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div>
<details class="template-picker">
<summary><AppIcon name="plus" /> Neues Piece</summary>
<div class="template-popover">
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><AppIcon name={template.iconName} size="1.35rem" /><strong
>{template.name}</strong
><span>{template.description}</span></button
>
</form>
{/each}
</div>
{#if data.templates.some((template) => template.id !== 'blank')}<details
class="template-management"
>
<summary>Vorlagen verwalten</summary>
{#each data.templates.filter((template) => template.id !== 'blank') as template (template.id)}
<form
method="POST"
action="?/deleteTemplate"
onsubmit={(event) =>
!confirm(`Vorlage „${template.name}“ löschen?`) && event.preventDefault()}
>
<input type="hidden" name="templateId" value={template.id} />
<input type="hidden" name="revision" value={template.revision} />
<span>{template.name}</span>
<button class="text-button danger-text" type="submit">Löschen</button>
</form>
{/each}
</details>{/if}
</div>
</details>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<PreviewNotice area="Fundus" />
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern">
<label class="search-field">
<span aria-hidden="true">⌕</span>
<span class="sr-only">Fundus durchsuchen</span>
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" />
<form
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<div class="fundus-search-row">
<label class="search-field"
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker">
<summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel">
<label
><span>Status</span><select name="status"
><option value="">Alle</option><option
value="ready"
selected={data.filters.status === 'ready'}>Sendefertig</option
><option value="preparing" selected={data.filters.status === 'preparing'}
>In Arbeit</option
></select
></label
>
<label
><span>Format</span><select name="format"
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
value={item.id}
selected={data.filters.formatId === item.id}>{item.name}</option
>{/each}</select
></label
>
<label
><span>Thema</span><select name="topic"
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
value={item.id}
selected={data.filters.topicId === item.id}>{item.name}</option
>{/each}</select
></label
>
<label
><span>Abschnitt</span><select name="category"
><option value="">Alle</option
>{#each data.taxonomy.categories as item (item.id)}<option
value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select
></label
>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
<div class="filter-tabs" aria-label="Status filtern">
<button class:active={filter === 'all'} type="button" onclick={() => (filter = 'all')}
>Alle</button
>
<button class:active={filter === 'ready'} type="button" onclick={() => (filter = 'ready')}
>Sendefertig</button
>
<button
class:active={filter === 'preparing'}
type="button"
onclick={() => (filter = 'preparing')}>In Arbeit</button
>
</div>
</section>
<div class="filter-actions">
<button class="secondary-button" type="submit">Filter anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</div>
</div>
</details>
</div>
</form>
{#if filteredPieces.length > 0}
{#if visiblePieces.length}
<div class="piece-grid">
{#each filteredPieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
{#each visiblePieces as piece (piece.id)}
<article
class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
>
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
<input type="hidden" name="pieceId" value={piece.id} />
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
<button
class="piece-favorite"
class:active={piece.isFavorite}
type="submit"
aria-label={piece.isFavorite
? `${piece.title} aus Favoriten entfernen`
: `${piece.title} als Favorit markieren`}
aria-pressed={piece.isFavorite}><AppIcon name="star" /></button
>
</form>
{#if piece.mediaId}<img
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
</span>{/if}
<div class="piece-primary">
<h2>{piece.title}</h2>
<p>{piece.summary}</p>
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
{#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} />
</div>
<div class="piece-classification">
<span class="category-label"><i></i>{piece.category}</span>
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
{#if piece.formatId}<span>{piece.format}</span
>{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
</div>
</div>
<div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span
>{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
>{#if piece.durationSeconds}<span
><b>{formatClock(piece.durationSeconds)}</b> geplant</span
>{/if}{#if piece.sourceCount}<span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
>{/if}</span
>{/if}
>
<span class="piece-facts">
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</span>
</div>
<details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
>
<div class="piece-actions-popover">
<form method="POST" action="?/duplicate">
<input type="hidden" name="pieceId" value={piece.id} /><button
class="text-button"
type="submit">Duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form>
</div>
</details>
</article>
{/each}
</div>
{:else}
<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Kein passendes Stück</h2>
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p>
<button
type="button"
onclick={() => {
query = '';
filter = 'all';
}}>Filter zurücksetzen</button
>
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Kein passendes Piece</h2>
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div>
{/if}
</div>
+286
View File
@@ -0,0 +1,286 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
deletePieceImage,
duplicatePiece,
getPiece,
getTaxonomy,
listPieceTemplates,
loadPiecePresentationMetadata,
savePiece,
savePieceAsTemplate,
setPieceArchived,
setPieceDeleted,
setPieceFavorite,
uploadPieceImage,
type EditablePieceStatus,
type PieceInput,
type PieceSourceInput
} from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
import { listTools } from '#lib/server/tools.js';
function renderedFields(
piece: Pick<
PieceInput,
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
>
) {
return {
intro: renderMarkdown(piece.intro),
research: renderMarkdown(piece.researchText),
moderation: renderMarkdown(piece.moderationNotes),
transition: renderMarkdown(piece.transitionText),
presentation: renderMarkdown(piece.presentationText)
};
}
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function durationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60);
}
function pieceInput(data: FormData): PieceInput {
const labels = data.getAll('sourceLabel').map(String);
const urls = data.getAll('sourceUrl').map(String);
const timecodes = data.getAll('sourceTimecode').map(String);
const sources: PieceSourceInput[] = [];
for (let index = 0; index < Math.max(labels.length, urls.length, timecodes.length); index += 1) {
const label = labels[index]?.trim() ?? '';
const url = urls[index]?.trim() ?? '';
const timecode = timecodes[index]?.trim() ?? '';
if (!label && !url && !timecode) continue;
sources.push({
label,
url,
timecodeSeconds: timecode ? Number(timecode) : null
});
}
const toolIds = data.getAll('toolId').map(String);
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
const toolNotes = data.getAll('toolNote').map(String);
return {
title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus,
durationSeconds: durationInSeconds(data),
intro: text(data, 'intro'),
researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'),
transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'),
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
presentationSourcePosition: text(data, 'presentationSourcePosition')
? Number(data.get('presentationSourcePosition'))
: null,
presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'),
presentationProvider: text(data, 'presentationProvider'),
presentationProviderId: text(data, 'presentationProviderId'),
presentationTitleManual: text(data, 'presentationTitleManual') === '1',
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
presentationImageManual: text(data, 'presentationImageManual') === '1',
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
presentationMediaId: text(data, 'presentationMediaId') || null,
topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String),
sources,
tools: toolIds.map((toolId, index) => ({
toolId,
workUrl: toolWorkUrls[index] ?? '',
note: toolNotes[index] ?? ''
}))
};
}
function saveError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Eine neuere Änderung liegt vor. Deine Eingaben wurden nicht überschrieben.',
conflict: true
};
if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' };
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Die Dauer muss zwischen 0 und 1.440 Minuten liegen.' };
if (code === 'INVALID_SOURCES')
return {
status: 400,
error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.'
};
if (code === 'INVALID_PRESENTATION')
return {
status: 400,
error: 'Für Linkvorschau und Video muss eine vorhandene gültige Quelle ausgewählt sein.'
};
if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return {
status: 500,
error: 'Speichern fehlgeschlagen. Deine Eingaben bleiben im Formular erhalten.'
};
}
export const load: PageServerLoad = ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv');
return {
piece,
taxonomy: getTaxonomy(),
tools: listTools(true),
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
rendered: renderedFields(piece)
};
};
export const actions = {
save: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const input = pieceInput(data);
try {
const saved = savePiece(params.id, Number(data.get('revision')), input);
return { intent: 'save', saved: true, ...saved, rendered: renderedFields(input) };
} catch (cause) {
const problem = saveError(cause);
return fail(problem.status, { intent: 'save', ...problem });
}
},
loadPreview: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
await loadPiecePresentationMetadata(params.id, Number(data.get('revision')));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'FETCH_BLOCKED') {
return fail(400, {
intent: 'loadPreview',
error: 'Interne oder private Netzwerkziele dürfen nicht abgerufen werden.'
});
}
if (code.startsWith('FETCH_')) {
return fail(400, {
intent: 'loadPreview',
error:
'Die Vorschau konnte nicht sicher geladen werden. Die manuellen Angaben bleiben erhalten.'
});
}
const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem });
}
redirect(303, `/fundus/${params.id}`);
},
saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const reference = text(data, 'templateRef');
const [templateId, revision] = reference.split('|');
try {
savePieceAsTemplate(
params.id,
text(data, 'templateName'),
text(data, 'templateDescription'),
templateId || undefined,
revision ? Number(revision) : undefined
);
return { intent: 'saveTemplate', success: 'Vorlage gespeichert.' };
} catch (cause) {
const problem = saveError(cause);
return fail(problem.status, { intent: 'saveTemplate', ...problem });
}
},
upload: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const image = data.get('image');
if (!(image instanceof File))
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
try {
await uploadPieceImage(
params.id,
Number(data.get('revision')),
image,
text(data, 'altText'),
data.get('selectForPresentation') === '1'
);
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return fail(409, {
intent: 'upload',
error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.'
});
if (code === 'INVALID_IMAGE_SIZE')
return fail(400, {
intent: 'upload',
error: 'Das Bild darf höchstens 5 MB groß und nicht leer sein.'
});
if (code === 'INVALID_IMAGE_TYPE')
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
}
redirect(303, `/fundus/${params.id}`);
},
deleteImage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
await deletePieceImage(params.id, text(data, 'mediaId'), Number(data.get('revision')));
} catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
}
redirect(303, `/fundus/${params.id}`);
},
duplicate: async ({ locals, params }) => {
requireUser(locals.user);
let id: string;
try {
id = await duplicatePiece(params.id);
} catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
}
redirect(303, `/fundus/${id}`);
},
archive: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(params.id, Number(data.get('revision')), true);
} catch (cause) {
return fail(409, { intent: 'archive', error: saveError(cause).error });
}
redirect(303, '/archiv');
},
favorite: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceFavorite(params.id, data.get('favorite') === '1');
return { intent: 'favorite', success: true };
} catch (cause) {
return fail(404, { intent: 'favorite', error: saveError(cause).error });
}
},
delete: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(params.id, Number(data.get('revision')), true);
} catch (cause) {
return fail(409, { intent: 'delete', error: saveError(cause).error });
}
redirect(303, '/fundus');
}
} satisfies Actions;
+791
View File
@@ -0,0 +1,791 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece })));
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
let rendered = $state(untrack(() => ({ ...data.rendered })));
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let sourceImageUnavailable = $state(false);
let editorDialog: { showModal: () => void; close: () => void };
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
let saving = $state(false);
let actionsBlocked = $derived(
saveState === 'dirty' ||
saveState === 'saving' ||
saveState === 'error' ||
saveState === 'conflict'
);
let featuredSource = $derived(
draft.presentationSourcePosition === null
? (sources[0] ?? null)
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
);
let sourcePreviewImage = $derived(
draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: ''
);
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
let previewContent = $derived<
PresentationContent & { images: { id: string; altText: string }[] }
>({
mode: draft.presentationMode,
topics: data.taxonomy.topics
.filter((topic) => draft.topicIds.includes(topic.id))
.map((topic) => topic.name),
title: draft.outputTitle || draft.title,
html: rendered.presentation,
source: featuredSource
? {
label: featuredSource.label || featuredSource.url,
url: featuredSource.url,
timecodeSeconds: featuredSource.timecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationImageUrl,
siteName: draft.presentationSiteName,
provider: draft.presentationProvider as PresentationContent['provider'],
providerId: draft.presentationProviderId,
embedUrl:
draft.presentationProvider === 'youtube' && draft.presentationProviderId
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: '',
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : []
});
let presentationPreviewImage = $derived(
selectedMedia
? draft.presentationMode === 'text'
? '/media'
: `/media/${selectedMedia.id}`
: sourcePreviewImage
);
let pendingHref: string | null = null;
let allowNavigation = false;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
if (allowNavigation) return;
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Seite verlassen?')) navigation.cancel();
return;
}
if (saveState !== 'dirty' && saveState !== 'saving') return;
navigation.cancel();
if (navigation.willUnload) return;
pendingHref = navigation.to?.url.href ?? null;
if (!saving) submitSave();
});
function scheduleSave(event?: Event) {
const control = event?.target;
if (event?.type === 'change' && !(control instanceof HTMLSelectElement)) return;
if (
control instanceof HTMLInputElement &&
control.name === 'presentationMode' &&
control.value !== 'text' &&
draft.presentationSourcePosition === null
) {
return;
}
generation += 1;
saveState = 'dirty';
saveMessage = 'Ungespeicherte Änderungen';
clearTimeout(timer);
timer = setTimeout(submitSave, 900);
}
function submitSave() {
if (saving) return;
editorForm.requestSubmit(saveButton);
}
function requestClose() {
const target = resolve('/fundus');
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
allowNavigation = true;
void goto(target);
return;
}
if (saveState === 'dirty' || saveState === 'saving') {
pendingHref = new URL(target, window.location.href).href;
if (!saving) submitSave();
return;
}
allowNavigation = true;
void goto(target);
}
function addSource() {
sources.push({ label: '', url: '', timecodeSeconds: null });
scheduleSave();
}
function removeSource(index: number) {
sources.splice(index, 1);
if (draft.presentationSourcePosition === index) draft.presentationSourcePosition = null;
else if (
draft.presentationSourcePosition !== null &&
draft.presentationSourcePosition > index
) {
draft.presentationSourcePosition -= 1;
}
scheduleSave();
}
function removeTool(index: number) {
pieceTools.splice(index, 1);
scheduleSave();
}
function selectTools(ids: string[]) {
for (const id of ids) {
if (pieceTools.some((tool) => tool.toolId === id)) continue;
const tool = data.tools.find((item) => item.id === id);
if (tool)
pieceTools.push({
id: crypto.randomUUID(),
toolId: tool.id,
name: tool.name,
startUrl: tool.startUrl,
iconName: tool.iconName,
isActive: tool.isActive,
workUrl: '',
note: ''
});
}
pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId));
scheduleSave();
}
function presentationSourceChanged() {
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
}
const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation;
saving = true;
saveState = 'saving';
saveMessage = 'Speichert …';
return async ({ result }) => {
saving = false;
if (result.type === 'success') {
const response = result.data as { revision: number; rendered: typeof rendered };
revision = response.revision;
rendered = response.rendered;
if (generation === submittedGeneration) {
saveState = 'saved';
saveMessage = 'Gespeichert';
if (pendingHref) {
const target = pendingHref;
pendingHref = null;
allowNavigation = true;
// The target comes from SvelteKit's own NavigationTarget, never from user input.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(new URL(target), { invalidateAll: true });
}
} else {
saveState = 'dirty';
saveMessage = 'Weitere Änderungen warten';
timer = setTimeout(submitSave, 250);
}
} else if (result.type === 'failure') {
const response = result.data as { error?: string; conflict?: boolean };
saveState = response.conflict ? 'conflict' : 'error';
saveMessage = response.error ?? 'Speichern fehlgeschlagen.';
} else {
saveState = 'error';
saveMessage = 'Speichern fehlgeschlagen.';
}
};
};
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<div class="piece-editor-route">
{#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error}
</div>{/if}
<PieceEditorDialog
bind:this={editorDialog}
bind:activeSection
labelledby="piece-editor-title"
onrequestclose={requestClose}
>
{#snippet header()}
<div class="piece-editor-kicker">
<p class="eyebrow">Fundusfassung</p>
<form method="POST" action="?/favorite" use:enhance>
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
<button
class="piece-favorite"
class:active={data.piece.isFavorite}
type="submit"
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
>
</form>
</div>
<InlineTitleEditor
form="piece-editor-form"
bind:value={draft.title}
oncommit={scheduleSave}
/>
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
{/snippet}
<form
bind:this={editorForm}
id="piece-editor-form"
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-dialog-form"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
/>
<MarkdownEditorField
name="moderationNotes"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderationNotes}
/>
<MarkdownEditorField
name="transitionText"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText}
/>
</div>
<details class="piece-meta-details">
<summary>
<span>Status, Dauer und Einordnung</span>
<small
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
>
</summary>
<div class="piece-meta-grid">
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing"
>In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
placeholder="Worum geht es in wenigen Sätzen?"
bind:value={draft.summary}></textarea></label
>
<fieldset>
<legend>Themen</legend><ChoicePicker
items={data.taxonomy.topics}
selectedIds={draft.topicIds}
label="Themen"
fieldName="topicIds"
onselectionchange={(ids) => {
draft.topicIds = ids;
scheduleSave();
}}
/>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</div>
</details>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h2>Recherche & Quellen</h2>
</div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<MarkdownEditorField
name="researchText"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.researchText}
/>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Fundstellen</p>
<h3>Quellen</h3>
</div>
<button class="secondary-button" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
placeholder="Name der Quelle"
bind:value={source.label}
/></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
placeholder="Optional"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
</section>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Arbeitsmittel</p>
<h3>Werkzeuge</h3>
</div>
<ChoicePicker
items={data.tools
.filter((tool) => tool.isActive)
.map((tool) => ({
id: tool.id,
name: tool.name,
description: tool.description,
iconName: tool.iconName,
meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : ''
}))}
selectedIds={pieceTools.map((tool) => tool.toolId)}
label="Werkzeuge"
onselectionchange={selectTools}
/>
</div>
{#if pieceTools.length}<div class="source-list">
{#each pieceTools as tool, index (tool.id)}<div
class="source-row tool-assignment-row"
>
<input type="hidden" name="toolId" value={tool.toolId} />
<div class="tool-assignment-name">
<AppIcon name={tool.iconName || 'wrench'} /><span
><strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small
>{/if}</span
>
</div>
<label
>Arbeitslink<input
name="toolWorkUrl"
type="url"
maxlength="2000"
placeholder={tool.startUrl || 'https://…'}
bind:value={tool.workUrl}
/></label
>
<label
>Notiz<input
name="toolNote"
maxlength="2000"
placeholder="Optional"
bind:value={tool.note}
/></label
>
<button
class="text-button danger-text"
type="button"
onclick={() => removeTool(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="context-empty">Noch kein Werkzeug zugeordnet.</p>{/if}
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
<h2>Präsentation</h2>
</div>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">In neuem Fenster testen</a
>
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>{/each}
</div>
<div class="presentation-settings">
<label
>Quelle<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
><option value={null}>Quelle auswählen</option
>{#each sources as source, index (index)}{#if source.url}<option value={index}
>{source.label || source.url}</option
>{/if}{/each}</select
></label
>
<label
>Kartentitel<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}</small
>
</div>
</div>
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
content={previewContent}
imageSrc={sourceImageUnavailable ? '' : presentationPreviewImage}
/>
</aside>
</div>
<input
type="hidden"
name="presentationTitleManual"
value={draft.presentationTitleManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationDescriptionManual"
value={draft.presentationDescriptionManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationImageManual"
value={draft.presentationImageManual ? '1' : '0'}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
</form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Hochgeladene Bilder</p>
<h3>Präsentationsbild</h3>
</div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div>
{#if data.piece.media.length}<label class="media-none"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value=""
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
scheduleSave();
}}
/> Kein hochgeladenes Bild</label
>
<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<label class="media-presentation-choice"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value={media.id}
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
scheduleSave();
}}
/><img src={`/media/${media.id}`} alt={media.altText} /><span
>Als Präsentationsbild</span
></label
>
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
<label class="upload-select"
><input type="checkbox" name="selectForPresentation" value="1" checked /> Direkt als Präsentationsbild
verwenden</label
>
</form>
</section>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<details class="template-save-menu">
<summary>Als Vorlage speichern</summary>
<form method="POST" action="?/saveTemplate">
<label
>Vorlage<select name="templateRef">
<option value="">Neue Vorlage</option>
{#each data.templates as template (template.id)}<option
value={`${template.id}|${template.revision}`}>{template.name}</option
>{/each}
</select></label
>
<label>Name<input name="templateName" required maxlength="120" /></label>
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
<button class="text-button" type="submit" disabled={actionsBlocked}
>Vorlage speichern</button
>
</form>
</details>
<form method="POST" action="?/duplicate">
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
aria-label="Piece archivieren"
disabled={actionsBlocked}>Archivieren</button
>
</form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece löschen</button
>
</form>
</div>
<div class="piece-dialog-primary-action">
<span
class="save-indicator"
class:problem={saveState === 'error' || saveState === 'conflict'}
role="status"
aria-live="polite">{saveMessage}</span
>
<button
bind:this={saveButton}
class:primary-button={saveState === 'dirty' ||
saveState === 'error' ||
saveState === 'conflict'}
class:secondary-button={saveState === 'idle' ||
saveState === 'saved' ||
saveState === 'saving'}
type="submit"
form="piece-editor-form"
disabled={saving}
>{saveState === 'error' || saveState === 'conflict'
? 'Erneut speichern'
: 'Speichern'}</button
>
</div>
{/snippet}
</PieceEditorDialog>
<form
bind:this={previewForm}
id="preview-load-form"
method="POST"
action="?/loadPreview"
use:enhance
>
<input type="hidden" name="revision" value={revision} />
</form>
</div>
@@ -0,0 +1,49 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getPiece, getTaxonomy } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
export const load: PageServerLoad = ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
const source =
piece.presentationSourcePosition === null
? null
: (piece.sources[piece.presentationSourcePosition] ?? null);
const content: PresentationContent & {
sources: typeof piece.sources;
images: { id: string; altText: string }[];
} = {
mode: piece.presentationMode,
topics: getTaxonomy()
.topics.filter((topic) => piece.topicIds.includes(topic.id))
.map((topic) => topic.name),
title: piece.outputTitle || piece.title,
html: renderMarkdown(piece.presentationText),
source,
previewTitle: piece.presentationTitle,
description: piece.presentationDescription,
imageUrl: piece.presentationImageUrl,
siteName: piece.presentationSiteName,
provider:
piece.presentationProvider === 'youtube' || piece.presentationProvider === 'tiktok'
? piece.presentationProvider
: '',
providerId: piece.presentationProviderId,
embedUrl: buildEmbedUrl(
piece.presentationProvider === 'youtube' || piece.presentationProvider === 'tiktok'
? piece.presentationProvider
: '',
piece.presentationProviderId,
source?.timecodeSeconds ?? null
),
sources: [],
images: piece.media
.filter((item) => item.id === piece.presentationMediaId)
.map((item) => ({ id: item.id, altText: item.altText }))
};
return { pieceId: piece.id, content };
};
@@ -0,0 +1,23 @@
<script lang="ts">
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>Präsentation testen | Sendewerk</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class="heinzlive-output presentation-test-output">
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay
content={data.content}
imageSrc={data.content.images.length
? data.content.mode === 'text'
? '/media'
: `/media/${data.content.images[0].id}`
: `/fundus/${data.pieceId}/praesentation/preview-image`}
/>
</main>
@@ -0,0 +1,26 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getPiece } from '#lib/server/content.js';
import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar');
try {
const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'none'; sandbox"
}
});
} catch {
error(404, 'Vorschaubild nicht verfügbar');
}
};
@@ -0,0 +1,10 @@
import type { PageServerLoad } from './$types';
import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
export const load: PageServerLoad = ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
const view = presentationChannelAccess(params.token, accessKey)
? getPresentationChannelView(params.token)
: null;
return { view, channelId: params.token };
};
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let view = $state(untrack(() => data.view ?? { revision: -1, content: null }));
let connection = $state<'connected' | 'waiting' | 'interrupted'>(
untrack(() => (data.view ? 'connected' : 'waiting'))
);
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);
}
async function connect() {
const accessKey = decodeURIComponent(location.hash.slice(1));
if (accessKey) {
const response = await fetch(`/heinzlive/${data.channelId}/access`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: accessKey })
});
if (!response.ok) {
connection = 'interrupted';
return;
}
}
timer = setTimeout(refresh, 0);
}
void connect();
return () => {
stopped = true;
clearTimeout(timer);
};
});
</script>
<svelte:head>
<title>heinzlive</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class:empty={!view.content} class="heinzlive-output">
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div>
{#if view.content}
{#key view.revision}
<PresentationDisplay
content={view.content}
imageSrc={view.content.images.length
? view.content.mode === 'text'
? `/heinzlive/${page.params.token}/media`
: `/heinzlive/${page.params.token}/media/${view.content.images[0].id}`
: `/heinzlive/${page.params.token}/preview-image`}
/>
{/key}
{/if}
</main>
@@ -0,0 +1,18 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presentationChannelAccess } from '#lib/server/live.js';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const body = (await request.json().catch(() => null)) as { key?: unknown } | null;
const accessKey = typeof body?.key === 'string' ? body.key : '';
if (!presentationChannelAccess(params.token, accessKey)) {
error(404, 'Präsentationsausgabe nicht gefunden');
}
cookies.set(`sendewerk-output-${params.token}`, accessKey, {
path: `/heinzlive/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: url.protocol === 'https:'
});
return json({ success: true }, { headers: { 'Cache-Control': 'no-store' } });
};
@@ -0,0 +1,20 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationMedia, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey)) error(404, 'Bild nicht gefunden');
const media = await getPresentationMedia(accessKey, params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(new Uint8Array(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,21 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationPreviewImage, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey))
error(404, 'Vorschaubild nicht verfügbar');
const image = await getPresentationPreviewImage(accessKey);
if (!image) error(404, 'Vorschaubild nicht verfügbar');
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'none'; sandbox"
}
});
};
@@ -0,0 +1,12 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
export const GET: RequestHandler = ({ params, cookies }) => {
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
if (!presentationChannelAccess(params.token, accessKey))
error(404, 'Präsentationsausgabe nicht gefunden');
const view = getPresentationChannelView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } });
};
+157 -13
View File
@@ -1,15 +1,159 @@
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,
clearPresentationChannel,
getLiveView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent,
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
export function load() {
const show = getShow();
const usages = show.sections.flatMap((section) =>
section.usages
.filter((usage) => !usage.isReserve)
.map((usage) => ({ ...usage, section: section.name }))
);
const currentIndex = Math.max(
0,
usages.findIndex((usage) => usage.status === 'current')
);
return { show, current: usages[currentIndex], next: usages[currentIndex + 1] ?? null };
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
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 === 'NOT_STARTED')
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === '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')))
);
},
select: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('select', () =>
selectUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => {
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/default#${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem });
}
},
rotateOutput: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const token = rotatePresentationChannelKey(text(data, 'showId'));
return { intent: 'rotateOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'rotateOutput', ...problem });
}
},
clearOutput: async ({ locals }) => {
requireUser(locals.user);
return attempt('clearOutput', () => clearPresentationChannel());
}
} satisfies Actions;
+759 -54
View File
@@ -1,92 +1,797 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock } from '#lib/format.js';
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { onMount, tick, untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.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 showRail = $state(true);
let mobileRailOpen = $state(false);
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 loadedRevision = $state(untrack(() => data.live.liveRevision));
let pieceScroller = $state<HTMLElement>();
let railScroller = $state<HTMLElement>();
let liveRail = $state<HTMLElement>();
let mobileRunButton = $state<HTMLButtonElement>();
let railCloseButton = $state<HTMLButtonElement>();
let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
let currentPosition = $derived(
data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0
);
let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
);
let presentedUsage = $derived(
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
);
let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
);
let needsResume = $derived(
Boolean(data.live.startedAtUtc && !data.live.current && data.live.next)
);
let hasOtherOpenUsages = $derived(
allUsages.some((usage) => usage.status === 'open' && usage.id !== data.live.current?.id)
);
let sessionComplete = $derived(
Boolean(
data.live.startedAtUtc &&
!data.live.current &&
data.live.totalCount &&
data.live.completedCount === data.live.totalCount
)
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
let sectionElapsedSeconds = $derived(
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
(data.live.sectionTimer?.runningSinceUtc
? Math.max(0, Math.floor((now - loadedAt) / 1000))
: 0)
);
let sectionRemainingSeconds = $derived(
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
);
let hasActiveSectionTimer = $derived(
Boolean(
data.live.sectionTimer &&
data.live.current &&
data.live.sectionTimer.sectionId === data.live.current.sectionId
)
);
$effect(() => {
const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
const changedPiece = nextCurrentId !== currentId;
currentId = nextCurrentId;
loadedRevision = data.live.liveRevision;
loadedAt = Date.now();
if (changedPiece) {
void tick().then(() => {
if (pieceScroller)
pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0;
});
}
}
});
$effect(() => {
const activeId = data.live.current?.id;
if (!activeId) return;
void tick().then(() => {
railScroller
?.querySelector<HTMLElement>('[data-current="true"]')
?.scrollIntoView({ block: 'nearest' });
});
});
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 (settings.scrollPositions && typeof settings.scrollPositions === 'object') {
scrollPositions = Object.fromEntries(
Object.entries(settings.scrollPositions).filter((entry): entry is [string, number] =>
Number.isFinite(entry[1])
)
);
} else if (currentId && typeof settings.scrollY === 'number') {
scrollPositions[currentId] = settings.scrollY;
}
requestAnimationFrame(() => {
if (pieceScroller && currentId) pieceScroller.scrollTop = scrollPositions[currentId] ?? 0;
});
} catch {
localStorage.removeItem(key);
}
const mobileBreakpoint = matchMedia('(max-width: 46rem)');
const handleBreakpointChange = (event: MediaQueryListEvent) => {
if (!event.matches) void closeMobileRail(false);
};
mobileBreakpoint.addEventListener('change', handleBreakpointChange);
const interval = setInterval(() => (now = Date.now()), 1000);
return () => {
mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
clearInterval(interval);
};
});
function persistSettings() {
if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop;
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({
promptSize,
showRail,
showTimes,
scrollPositions
})
);
}
function changePromptSize(delta: number) {
promptSize = Math.min(2.4, Math.max(1.1, promptSize + delta));
persistSettings();
}
function toggleDesktopRail() {
showRail = !showRail;
persistSettings();
}
async function openMobileRail() {
mobileRailOpen = true;
await tick();
railCloseButton?.focus();
}
async function closeMobileRail(restoreFocus = true) {
if (!mobileRailOpen) return;
mobileRailOpen = false;
await tick();
if (restoreFocus) mobileRunButton?.focus();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (mobileRailOpen && event.key === 'Escape') {
event.preventDefault();
void closeMobileRail();
}
}
function trapRailFocus(event: KeyboardEvent) {
if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return;
const focusable = Array.from(
liveRail.querySelectorAll<HTMLElement>(
'button:not(:disabled), summary, a[href], input:not(:disabled), [tabindex]:not([tabindex="-1"])'
)
).filter((element) => element.offsetParent !== null);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function toggleTimes() {
showTimes = !showTimes;
persistSettings();
}
function formatCountdown(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
return hours > 0
? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
function statusLabel(status: string) {
if (status === 'current') return 'Aktuell';
if (status === 'done') return 'Erledigt';
if (status === 'skipped') return 'Übersprungen';
return 'Kommend';
}
function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
const submittedFromRail = Boolean(formElement.closest('.live-rail'));
const keepRailOpen = formElement.dataset.keepRailOpen === 'true';
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 ({ result, update }) => {
await update({ reset: false, invalidateAll: true });
if (submittedFromRail && (result.type !== 'success' || !keepRailOpen)) {
await closeMobileRail();
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
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>
<svelte:head><title>Live-Vorschau | Sendewerk</title></svelte:head>
<svelte:window onkeydown={handleWindowKeydown} />
<div class="page-wrap live-page">
<header class="live-header">
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
<div class:without-rail={!showRail} class="live-page live-controller">
<header class="live-command-header" inert={mobileRailOpen}>
<div class="live-section-context">
<span class="live-pulse" aria-hidden="true"></span>
<div>
<p class="eyebrow"><span class="live-pulse"></span> Live-Modus · Vorschau</p>
<h1>{data.show.title}</h1>
<small>{data.live.show.title}</small>
<strong>{currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')}</strong>
</div>
<div class="live-header-actions">
</div>
<div
class:warning={hasActiveSectionTimer &&
sectionRemainingSeconds <= 120 &&
sectionRemainingSeconds >= 0}
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span>Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</strong>
</div>
<div class:active={Boolean(presentedUsage)} class="live-output-state" aria-live="polite">
<span aria-hidden="true"></span>
<div>
<small>Ausgabe</small>
<strong>{presentedUsage?.title ?? 'Leer'}</strong>
</div>
</div>
<button
bind:this={mobileRunButton}
class="live-mobile-run-button"
type="button"
aria-controls="live-run-sheet"
aria-expanded={mobileRailOpen}
onclick={openMobileRail}
>
Ablauf · Piece {currentPosition || data.live.completedCount}/{data.live.totalCount}
</button>
<details class="live-menu">
<summary>Menü</summary>
<div class="live-menu-popover">
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => (promptSize = Math.max(1.15, promptSize - 0.1))}
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => (promptSize = Math.min(2.1, promptSize + 0.1))}
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<a href={resolve('/sendungen')}>Planung schließen</a>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<button
type="button"
class="text-button live-desktop-rail-toggle"
onclick={toggleDesktopRail}
>
{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendung</strong>
{#if needsResume}<form
method="POST"
action={liveAction('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="text-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div>
</details>
</header>
<PreviewNotice area="Live-Modus" />
<div class="live-messages">
{#if form?.error}<div class="live-message form-message error" role="alert">
{form.error}
</div>{/if}
{#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if}
</div>
<div class="live-grid">
<section class="live-current" style={`--prompt-size: ${promptSize}rem`}>
<div class="live-kicker">
<span>Aktuell · {data.current.section}</span><strong
>{formatClock(data.current.durationSeconds)}</strong
<div class="live-workspace">
{#if data.live.current}<article
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
inert={mobileRailOpen}
>
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading">
<div class="live-kicker">
<span>Piece {currentPosition} von {data.live.totalCount}</span>
{#if showTimes}<strong>
{formatClock(currentActualSeconds)} / {formatClock(
data.live.current.durationSeconds
)}
</strong>{/if}
</div>
<h2>{data.current.title}</h2>
<div class="prompter-copy">{data.current.intro}</div>
<div class="moderation-card">
<span>Moderationsstichpunkte</span>
<p>{data.current.moderation}</p>
<h1 id="current-title">{data.live.current.title}</h1>
</div>
<details class="research-details">
<summary>Recherche öffnen</summary>
<p>
Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim
Öffnen die Leseposition.
</p>
</details>
<div class="transition-card">
<span>Überleitung</span>
<p>{data.current.transition}</p>
</div>
</section>
<aside class="live-rail">
<div class="next-card">
<span>Als Nächstes</span>
{#if data.next}
<h3>{data.next.title}</h3>
<p>{data.next.section} · {formatClock(data.next.durationSeconds)}</p>
{:else}
<h3>Sendungsende</h3>
<p>Kein weiteres Stück eingeplant.</p>
{#if data.live.current.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span>
<!-- 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>
</section>{/if}
{#if data.live.current.moderationHtml}<section
class="live-script-section 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}
{#if data.live.current.transitionHtml}<section
class="live-script-section transition-card"
>
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
</section>{/if}
{#if data.live.current.researchHtml || data.live.current.sources.length || data.live.current.tools.length || data.live.current.ownNotes}
<!-- External source and tool URLs are server-validated private snapshots. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="live-support-actions" aria-label="Private Zusatzinformationen">
{#if data.live.current.researchHtml}<details class="research-details">
<summary>Recherche</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
</details>{/if}
{#if data.live.current.sources.length}<details class="live-materials">
<summary>Quellen · {data.live.current.sources.length}</summary>
<ul>
{#each data.live.current.sources as source (source.id)}<li>
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer"
>{source.label}</a
>
{#if source.timecodeSeconds !== null}<small
>ab {formatClock(source.timecodeSeconds)}</small
>{/if}
</li>{/each}
</ul>
</details>{/if}
{#if data.live.current.tools.length}<details class="live-materials">
<summary>Werkzeuge · {data.live.current.tools.length}</summary>
<ul>
{#each data.live.current.tools as tool (tool.id)}<li>
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href={tool.workUrl || tool.startUrl}
target="_blank"
rel="noopener noreferrer">{tool.name}</a
>
{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
{#if tool.note}<small>{tool.note}</small>{/if}
</li>{/each}
</ul>
</details>{/if}
{#if data.live.current.ownNotes}<details class="live-materials">
<summary>Eigene Notizen</summary>
<p>{data.live.current.ownNotes}</p>
</details>{/if}
</div>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{#if !data.live.current.introHtml && !data.live.current.moderationHtml && !data.live.current.transitionHtml}
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
{/if}
</div>
<div class="live-progress">
<header><span>Ablauf</span><strong>2 / 4</strong></header>
<div class="progress-track"><span></span></div>
{#each data.show.sections as section, index (section.id)}
<div class:active={index === 1} class="rail-section">
<span>{String(index + 1).padStart(2, '0')}</span>
</article>{:else}<section class="live-current live-empty" inert={mobileRailOpen}>
<div>
<strong>{section.name}</strong><small>{formatClock(section.durationSeconds)}</small>
<p class="eyebrow">{sessionComplete ? 'Ablauf abgeschlossen' : 'Live-Stand'}</p>
<h1>
{sessionComplete
? 'Alle Pieces bearbeitet'
: data.live.totalCount
? 'Bereit für die Sendung'
: 'Noch kein Piece eingeplant'}
</h1>
<p>
{sessionComplete
? 'Der Live-Ablauf ist am Ende. Die Sendung wird hier nicht automatisch abgeschlossen.'
: data.live.totalCount
? 'Starte oder setze den gespeicherten Live-Stand fort.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
</p>
{#if !data.live.startedAtUtc || needsResume}<form
method="POST"
action={liveAction('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>{/if}
</div>
</section>{/if}
<button
class:visible={mobileRailOpen}
class="live-rail-backdrop"
type="button"
aria-label="Ablauf schließen"
onclick={() => closeMobileRail()}
></button>
<aside
bind:this={liveRail}
class:open={mobileRailOpen}
class="live-rail"
id="live-run-sheet"
role={mobileRailOpen ? 'dialog' : undefined}
aria-modal={mobileRailOpen ? 'true' : undefined}
aria-labelledby="live-run-title"
onkeydown={trapRailFocus}
>
<header>
<div>
<strong id="live-run-title">Ablauf</strong>
<span>{data.live.completedCount}/{data.live.totalCount} bearbeitet · {progress}%</span>
</div>
<button
bind:this={railCloseButton}
type="button"
aria-label="Ablauf schließen"
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
>
</header>
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
<div class="live-run-scroll" bind:this={railScroller}>
{#each data.live.sections as section, sectionIndex (section.id)}
<details
class:active={section.id === data.live.current?.sectionId}
class="rail-section"
open={section.id === data.live.current?.sectionId ||
(!data.live.current &&
sectionIndex ===
data.live.sections.findIndex((candidate) => candidate.usages.length))}
>
<summary>
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong>
<small
>{section.usages.length} Pieces{#if showTimes}
· {formatDuration(section.durationSeconds)}{/if}</small
>
</div>
</summary>
<div class="rail-section-items">
{#each section.usages as usage, usageIndex (usage.id)}
<div
class:current={usage.id === data.live.current?.id}
class:done={usage.status === 'done'}
class:skipped={usage.status === 'skipped'}
class="live-run-item"
data-current={usage.id === data.live.current?.id}
>
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="live-run-select"
type="submit"
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
aria-label={`${usage.title} als aktuelles Piece auswählen`}
>
<strong>{usage.title}</strong>
<small>{statusLabel(usage.status)}</small>
</button>
</form>
<div class="live-run-actions">
{#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary
>
<div>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('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`}
><AppIcon name="arrow-up" /></button
>
</form>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('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`}
><AppIcon name="arrow-down" /></button
>
</form>
</div>
</details>{/if}
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={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`}
><AppIcon name="display" /></button
>
</form>
</div>
</div>
{/each}
{#if !section.usages.length}<p>Keine Pieces in diesem Abschnitt.</p>{/if}
</div>
<div class="readonly-actions" aria-label="Spätere Live-Aktionen">
<span>Zurück</span><strong>In Etappe 5 steuerbar</strong><span>Erledigen</span>
</details>
{/each}
<details class="live-reserve">
<summary><span>Reserve</span><strong>{data.live.reserve.length}</strong></summary>
<div>
{#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-reserve-actions">
<details class="live-run-order">
<summary aria-label={`${usage.title} in Reserve sortieren`}
><AppIcon name="ellipsis" /></summary
>
<div>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('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`}
><AppIcon name="arrow-up" /></button
>
</form>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('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`}
><AppIcon name="arrow-down" /></button
>
</form>
</div>
</details>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action={liveAction('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}
</div>
</details>
</div>
</aside>
</div>
<footer class="live-action-bar" inert={mobileRailOpen}>
<div class="live-next-summary">
<span>{data.live.current ? 'Nächstes Piece' : 'Live-Stand'}</span>
<strong>
{data.live.next?.title ??
(sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')}
</strong>
</div>
<div class="live-footer-actions">
{#if data.live.presentationUsageId}<form
class="live-clear-form"
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
{#if data.live.current}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
</form>
<form method="POST" action={liveAction('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="secondary-button" type="submit">Überspringen</button>
</form>
<form method="POST" action={liveAction('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">
{data.live.next
? 'Erledigen & weiter'
: hasOtherOpenUsages
? 'Erledigen'
: 'Erledigen & Sendungsende'}
</button>
</form>
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if}
</div>
</footer>
</div>
+36
View File
@@ -0,0 +1,36 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
authenticate,
createSession,
hasUsers,
reserveLoginAttempt,
validEmail
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
if (!hasUsers()) redirect(303, '/setup');
if (locals.user) redirect(303, '/fundus');
};
export const actions = {
default: async ({ request, cookies, getClientAddress }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
const address = getClientAddress();
if (!validEmail(email) || !password || Buffer.byteLength(password, 'utf8') > 256) {
return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
}
if (!reserveLoginAttempt(email, address)) {
return fail(429, {
email,
error: 'Zu viele Anmeldeversuche. Bitte in 15 Minuten erneut versuchen.'
});
}
const user = await authenticate(email, password);
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
createSession(user.id, cookies);
redirect(303, '/fundus');
}
} satisfies Actions;
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<svelte:head><title>Anmelden | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Redaktionszugang</p>
<h1>Bei Sendewerk anmelden</h1>
<p>Für Barkeeper und Thekencrew. Eine öffentliche Registrierung gibt es nicht.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
maxlength="256"
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Anmelden</button>
</form>
<a class="auth-link" href={resolve('/passwort-vergessen')}>Passwort vergessen?</a>
</section>
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteSession } from '#lib/server/auth.js';
export const POST: RequestHandler = ({ cookies }) => {
deleteSession(cookies);
redirect(303, '/login');
};
+20
View File
@@ -0,0 +1,20 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getMediaFile } from '#lib/server/content.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const media = await getMediaFile(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': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -0,0 +1,22 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { requestPasswordReset, reservePasswordResetRequest, validEmail } from '#lib/server/auth.js';
export const actions = {
default: async ({ request, getClientAddress }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
if (!validEmail(email))
return fail(400, { email, error: 'Bitte eine gültige E-Mail eingeben.' });
if (!reservePasswordResetRequest(email, getClientAddress())) {
return { sent: true };
}
try {
await requestPasswordReset(email);
} catch {
// Die sichtbare Antwort bleibt identisch, damit der Mailzustand kein Konto verrät.
console.error('Eine Passwortreset-Nachricht konnte nicht bereitgestellt werden.');
}
return { sent: true };
}
} satisfies Actions;
@@ -0,0 +1,31 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<svelte:head><title>Passwort vergessen | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Kontozugang</p>
<h1>Passwort zurücksetzen</h1>
<p>Wenn das Konto existiert, wird ein 24 Stunden gültiger, einmaliger Link bereitgestellt.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.sent}<div class="form-message success" role="status">
Anfrage verarbeitet. Bitte Postfach beziehungsweise lokale Outbox prüfen.
</div>{/if}
<form class="auth-form" method="POST">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<button class="primary-button" type="submit">Reset-Link anfordern</button>
</form>
<a class="auth-link" href={resolve('/login')}>Zurück zur Anmeldung</a>
</section>
@@ -0,0 +1,39 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
deleteLinkToken,
passwordResetIsValid,
readLinkToken,
RESET_COOKIE,
resetPassword,
storeLinkToken,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ url, cookies }) => {
const queryToken = url.searchParams.get('token');
if (queryToken) {
if (queryToken.length <= 100) storeLinkToken(cookies, RESET_COOKIE, queryToken);
else deleteLinkToken(cookies, RESET_COOKIE);
redirect(303, '/passwort-zuruecksetzen');
}
return { valid: passwordResetIsValid(readLinkToken(cookies, RESET_COOKIE)) };
};
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const token = readLinkToken(cookies, RESET_COOKIE);
const password = String(data.get('password') || '');
if (!validPassword(password)) {
return fail(400, { error: 'Das neue Passwort muss mindestens 12 Zeichen lang sein.' });
}
try {
await resetPassword(token, password);
deleteLinkToken(cookies, RESET_COOKIE);
} catch {
return fail(400, { error: 'Der Link ist ungültig, abgelaufen oder bereits verwendet.' });
}
redirect(303, '/login?reset=erfolgreich');
}
} satisfies Actions;
@@ -0,0 +1,33 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Neues Passwort | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Kontozugang</p>
<h1>Neues Passwort setzen</h1>
{#if !data.valid && !form?.error}
<div class="form-message error" role="alert">
Der Link ist ungültig, abgelaufen oder bereits verwendet.
</div>
<a class="auth-link" href={resolve('/passwort-vergessen')}>Neuen Link anfordern</a>
{:else}
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Neues Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<button class="primary-button" type="submit">Passwort speichern</button>
</form>
{/if}
</section>
+72 -3
View File
@@ -1,5 +1,74 @@
import { getPieces, getShow } from '#lib/server/previews.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
export function load() {
return { pieces: getPieces(), show: getShow() };
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() };
};
export const actions = {
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const title = String(data.get('title') || '');
const startsAt = String(data.get('startsAt') || '');
const templateId = String(data.get('templateId') || '') || null;
let id: string;
try {
id = createShow({ title, startsAtUtc: berlinLocalToUtc(startsAt), templateId }).id;
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'INVALID_TITLE') {
return fail(400, { error: 'Bitte einen Titel eingeben.', title, startsAt, templateId });
}
if (code === 'INVALID_TIMESTAMP') {
return fail(400, {
error: 'Bitte einen gültigen Termin eingeben.',
title,
startsAt,
templateId
});
}
return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.',
title,
startsAt,
templateId
});
}
redirect(303, `/sendungen/${id}`);
}
} satisfies Actions;
+87 -104
View File
@@ -1,130 +1,113 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let plannedCount = $derived(
data.show.sections.reduce(
(total, section) => total + section.usages.filter((usage) => !usage.isReserve).length,
0
)
);
let { data, form }: PageProps = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<div class="page-wrap shows-page">
<header class="page-heading">
<div>
<h1>Sendungen</h1>
<p class="show-title">{data.show.title}</p>
<p class="show-date">{data.show.startsAtBerlin} Uhr</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve('/live')}>Live-Vorschau <span>↗</span></a>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
</div>
</header>
<PreviewNotice area="Sendungsbau" />
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button
class:active={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
>
<button class:active={mobilePane === 'plan'} type="button" onclick={() => (mobilePane = 'plan')}
>Ablauf</button
>
<details class="show-create-panel">
<summary><AppIcon name="plus" /> Neue Sendung planen</summary>
<div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2>
</div>
<form method="POST" action="?/create" class="show-create-form">
<label
>Titel<input
name="title"
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung"
/></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label
>
<fieldset class="show-template-grid">
<legend>Ausgangspunkt</legend>
<label
><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
name="newspaper"
/><span
><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span
></label
>
{#each data.templates as template (template.id)}<label
><input
type="radio"
name="templateId"
value={template.id}
checked={form?.templateId === template.id}
/><AppIcon name="list-check" /><span
><strong>{template.name}</strong><small>{template.description}</small></span
></label
>{/each}
</fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button>
</form>
</details>
<div class="builder-workspace">
<section
class="builder-pane source-pane"
class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
<div>
<span class="category-label"><i></i>{piece.category}</span><span
>{formatClock(piece.durationSeconds)}</span
>
</div>
<h3>{piece.title}</h3>
<p>{piece.format} · {piece.topics.join(', ')}</p>
</article>
{/each}
</div>
<div class="reserve-box">
<div>
<span aria-hidden="true">◇</span>
<div><strong>Reservebank</strong><small>1 Stück vorbereitet</small></div>
</div>
<p>„Pixelart, die nicht nostalgisch sein will“ wartet als Ersatz.</p>
</div>
</section>
<section
class="builder-pane run-pane"
class:mobile-hidden={mobilePane !== 'plan'}
aria-labelledby="run-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
</header>
<div class="run-list">
{#each data.show.sections as section, index (section.id)}
<article class="run-section" style={`--category: ${section.categoryColor}`}>
<section class="show-library" aria-labelledby="show-library-title">
<header>
<div class="section-number">{String(index + 1).padStart(2, '0')}</div>
<div>
<span class="category-label"><i></i>{section.category}</span>
<h3>{section.name}</h3>
</div>
<strong>{formatClock(section.durationSeconds)}</strong>
<h2 id="show-library-title">Geplante Sendungen</h2>
<span>{data.shows.length}</span>
</header>
{#if section.usages.filter((usage) => !usage.isReserve).length > 0}
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
{#if data.shows.length}
<div class="show-card-list">
{#each data.shows as show (show.id)}
<a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Stand für diese Sendung</small
<span class={`show-state ${show.status}`}
>{show.status === 'ready'
? 'Bereit'
: show.status === 'draft'
? 'Entwurf'
: show.status === 'live'
? 'Live'
: 'Abgeschlossen'}</span
>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
>{usage.status === 'current' ? 'Aktuell' : 'Offen'}</span
>
<div class="show-card-facts">
{#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
>{/if}{#if show.totalDurationSeconds}<span
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
>{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>
{/each}
</div>
{:else}
<div class="section-placeholder">Spieleblock ohne vorbereiteten Fundusinhalt</div>
{/if}
</article>
{/each}
<div class="empty-state">
<span aria-hidden="true"><AppIcon name="plus" /></span>
<h2>Noch keine Sendung</h2>
<p>Oben kann der erste Stream geplant werden.</p>
</div>
{/if}
</section>
</div>
</div>
+292
View File
@@ -0,0 +1,292 @@
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 { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import {
addPieceUsage,
addShowSection,
createAndAddPieceUsage,
duplicatePieceUsage,
getShow,
getPieceUsageContexts,
movePieceUsage,
moveShowSection,
refreshPieceUsageFromOriginal,
removePieceUsage,
removeShowSection,
saveShowMetadata,
updatePieceUsage,
updateShowSection,
type EditableShowStatus,
type PieceUsageUpdate
} from '#lib/server/shows.js';
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function nullableDuration(data: FormData) {
const value = text(data, 'explicitDurationSeconds').trim();
return value === '' ? null : Number(value);
}
function pieceDurationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60);
}
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
candidate +=
localMilliseconds -
Date.UTC(read('year'), read('month') - 1, read('day'), read('hour'), read('minute'));
}
return new Date(candidate).toISOString();
}
function berlinInput(value: string | null) {
if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(value));
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`;
}
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.',
conflict: true
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
code
)
)
return {
status: 409,
error:
'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
};
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' };
}
function attempt(intent: string, operation: () => unknown) {
try {
return { intent, success: 'Änderung gespeichert.', result: operation() };
} catch (cause) {
return fail(actionError(cause).status, { intent, ...actionError(cause) });
}
}
export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user);
const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden');
const pieces = listPieces({
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
});
const usageContexts = getPieceUsageContexts(
pieces.map((piece) => piece.id),
show.id
);
return {
show,
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
pieceTemplates: listPieceTemplates(),
createCommandId: randomUUID(),
filters: {
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
}
};
};
export const actions = {
saveMetadata: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('saveMetadata', () =>
saveShowMetadata(params.id, Number(data.get('revision')), {
title: text(data, 'title'),
startsAtUtc: berlinLocalToUtc(text(data, 'startsAt')),
twitchTitle: text(data, 'twitchTitle'),
announcementText: text(data, 'announcementText'),
status: text(data, 'status') as EditableShowStatus
})
);
},
addSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('addSection', () =>
addShowSection(params.id, Number(data.get('revision')), {
name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data)
})
);
},
updateSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('updateSection', () =>
updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), {
name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data)
})
);
},
removeSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('removeSection', () =>
removeShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')))
);
},
moveSection: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveSection', () =>
moveShowSection(
params.id,
text(data, 'sectionId'),
Number(data.get('position')),
Number(data.get('revision'))
)
);
},
addUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('addUsage', () =>
addPieceUsage(params.id, Number(data.get('revision')), {
pieceId: text(data, 'pieceId'),
sectionId: text(data, 'sectionId') || null
})
);
},
createUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('createUsage', () =>
createAndAddPieceUsage(params.id, Number(data.get('revision')), text(data, 'commandId'), {
title: text(data, 'title'),
templateId: text(data, 'templateId') || 'blank',
sectionId: text(data, 'sectionId') || null
})
);
},
updateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const toolIds = data.getAll('usageToolId').map(String);
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
const toolNotes = data.getAll('toolNote').map(String);
return attempt('updateUsage', () =>
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
title: text(data, 'title'),
outputTitle: text(data, 'outputTitle'),
durationSeconds: pieceDurationInSeconds(data),
ownNotes: text(data, 'ownNotes'),
intro: text(data, 'intro'),
research: text(data, 'research'),
moderation: text(data, 'moderation'),
transition: text(data, 'transition'),
presentation: text(data, 'presentation'),
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
presentationSourceLabel: text(data, 'presentationSourceLabel'),
presentationSourceUrl: text(data, 'presentationSourceUrl'),
presentationSourceTimecodeSeconds: text(data, 'presentationSourceTimecodeSeconds')
? Number(data.get('presentationSourceTimecodeSeconds'))
: null,
presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'),
presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({
id,
workUrl: toolWorkUrls[index] ?? '',
note: toolNotes[index] ?? ''
}))
})
);
},
duplicateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('duplicateUsage', () =>
duplicatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
refreshUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('refreshUsage', () =>
refreshPieceUsageFromOriginal(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
removeUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('removeUsage', () =>
removePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
moveUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveUsage', () =>
movePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
sectionId: text(data, 'sectionId') || null,
position: Number(data.get('position'))
})
);
}
} satisfies Actions;
+923
View File
@@ -0,0 +1,923 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { replaceState } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { flip } from 'svelte/animate';
import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let dragged = $state<{ kind: 'piece' | 'section' | 'usage'; id: string } | null>(null);
let dropPreview = $state<
| { kind: 'section'; position: number }
| { kind: 'usage'; sectionId: string | null; position: number }
| null
>(null);
let dropTarget = $state<string | null>(null);
let moveSectionForm: HTMLFormElement;
let moveUsageForm: HTMLFormElement;
let addUsageForm: HTMLFormElement;
let removeUsageForm: HTMLFormElement;
let draggedSectionId = $state('');
let draggedUsageId = $state('');
let draggedPieceId = $state('');
let removedUsageId = $state('');
let dropSectionId = $state('');
let dropPosition = $state(0);
let copyMessage = $state('');
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
(showPlannedPieces || !piece.isPlannedInCurrentShow)
)
);
let plannedCount = $derived(
data.show.sections.reduce((total, section) => total + section.usages.length, 0)
);
onMount(() => {
const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams;
showUsedPieces = search.get('includeUsed') === '1';
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date(value));
}
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
const url = new URL(page.url.href);
if (checked) url.searchParams.set(name, '1');
else url.searchParams.delete(name);
// This URL is cloned from the current page; only same-route filter parameters change.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
}
function startDrag(kind: 'piece' | 'section' | 'usage', id: string, event: DragEvent) {
dragged = { kind, id };
event.dataTransfer?.setData('text/plain', id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = kind === 'piece' ? 'copy' : 'move';
}
function finishDrag() {
dragged = null;
dropPreview = null;
dropTarget = null;
}
function draggedTitle() {
if (!dragged) return '';
const draggedId = dragged.id;
if (dragged.kind === 'piece') {
return data.pieces.find((piece) => piece.id === draggedId)?.title ?? 'Piece';
}
if (dragged.kind === 'section') {
return data.show.sections.find((section) => section.id === draggedId)?.name ?? 'Abschnitt';
}
for (const section of data.show.sections) {
const usage = section.usages.find((item) => item.id === draggedId);
if (usage) return usage.title;
}
return data.show.reserve.find((usage) => usage.id === draggedId)?.title ?? 'Piece';
}
function positionAtPointer(event: DragEvent, position: number) {
const target = event.currentTarget as HTMLElement;
const bounds = target.getBoundingClientRect();
return event.clientY < bounds.top + bounds.height / 2 ? position : position + 1;
}
function previewSection(event: DragEvent, position: number) {
event.preventDefault();
if (dragged?.kind !== 'section') return;
dropPreview = { kind: 'section', position: positionAtPointer(event, position) };
dropTarget = null;
}
function previewUsage(
event: DragEvent,
sectionId: string | null,
position: number,
atPointer = true
) {
event.preventDefault();
if (!dragged || dragged.kind === 'section') return;
event.stopPropagation();
dropPreview = {
kind: 'usage',
sectionId,
position: atPointer ? positionAtPointer(event, position) : position
};
dropTarget = sectionId ?? 'reserve';
}
function dropAtPreview() {
if (!dropPreview) return;
if (dropPreview.kind === 'section') dropSection(dropPreview.position);
else dropUsage(dropPreview.sectionId, dropPreview.position);
}
function showsUsagePreview(sectionId: string | null, position?: number) {
return (
dropPreview?.kind === 'usage' &&
dropPreview.sectionId === sectionId &&
(position === undefined || dropPreview.position === position)
);
}
function dropSection(position: number) {
if (!dragged || dragged.kind !== 'section') return;
draggedSectionId = dragged.id;
dropPosition = position;
finishDrag();
setTimeout(() => moveSectionForm.requestSubmit());
}
function dropUsage(sectionId: string | null, position: number) {
if (!dragged || dragged.kind === 'section') return;
dropSectionId = sectionId ?? '';
dropPosition = position;
if (dragged.kind === 'piece') {
draggedPieceId = dragged.id;
finishDrag();
setTimeout(() => addUsageForm.requestSubmit());
return;
}
draggedUsageId = dragged.id;
finishDrag();
setTimeout(() => moveUsageForm.requestSubmit());
}
function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) {
finishDrag();
return;
}
removedUsageId = dragged.id;
finishDrag();
setTimeout(() => removeUsageForm.requestSubmit());
}
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
const affected = formElement.closest<HTMLElement>(
'.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form'
);
affected?.classList.add('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') formElement.closest<HTMLDialogElement>('dialog')?.close();
await update({ reset: result.type === 'success', invalidateAll: true });
affected?.classList.remove('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
async function copyText(label: string, value: string) {
try {
await navigator.clipboard.writeText(value);
copyMessage = `${label} kopiert.`;
} catch {
copyMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
</script>
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a>
<h1>{data.show.title}</h1>
<p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Pieces</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
>Live-Steuerung <AppIcon name="display" /></a
>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings">
<summary>Sendungsdaten und Twitch-Texte</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/saveMetadata"
class="show-settings-form"
>
<input type="hidden" name="revision" value={data.show.revision} />
<label class="wide-field"
>Interner Titel<input
name="title"
required
maxlength="200"
value={data.show.title}
/></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={data.startsAtLocal} /><small
>Die Anzeige erfolgt immer in Europe/Berlin.</small
></label
>
<label
>Status<select name="status" value={data.show.status}
><option value="draft">Entwurf</option><option value="ready">Bereit</option><option
value="live">Live</option
></select
></label
>
<label class="wide-field"
>Twitch-Titel<span class="copy-field"
><input name="twitchTitle" maxlength="200" value={data.show.twitchTitle} /><button
type="button"
onclick={() => copyText('Twitch-Titel', data.show.twitchTitle)}>Kopieren</button
></span
></label
>
<label class="wide-field"
>Ankündigung<span class="copy-field"
><textarea name="announcementText" maxlength="20000" rows="4"
>{data.show.announcementText}</textarea
><button type="button" onclick={() => copyText('Ankündigung', data.show.announcementText)}
>Kopieren</button
></span
></label
>
<div class="wide-field settings-submit">
<span>{copyMessage}</span><button class="primary-button" type="submit"
>Sendungsdaten speichern</button
>
</div>
</form>
</details>
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button
class:active={mobilePane === 'fundus'}
aria-pressed={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
>
<button
class:active={mobilePane === 'plan'}
aria-pressed={mobilePane === 'plan'}
type="button"
onclick={() => (mobilePane = 'plan')}>Ablauf</button
>
</div>
<div class="builder-workspace">
<section
class="builder-pane source-pane"
class:drop-ready={dropTarget === 'source'}
class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title"
ondragover={(event) => {
if (dragged?.kind !== 'usage') return;
event.preventDefault();
dropTarget = 'source';
}}
ondrop={(event) => {
event.preventDefault();
removeDraggedUsage();
}}
>
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{visiblePieces.length}</span>
</header>
{#if dropTarget === 'source'}<div class="drag-remove-target">
<strong>Aus Sendung entfernen</strong><span>Das Fundus-Original bleibt erhalten.</span>
</div>{/if}
<details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary>
<form use:enhance={enhanceCommand} method="POST" action="?/createUsage">
<input type="hidden" name="revision" value={data.show.revision} />
<input type="hidden" name="commandId" value={data.createCommandId} />
<label>Titel<input name="title" required maxlength="200" /></label>
<label
>Vorlage<select name="templateId">
{#each data.pieceTemplates as template (template.id)}<option value={template.id}
>{template.name}</option
>{/each}
</select></label
>
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}
</select></label
>
<button class="secondary-button" type="submit">Anlegen und einplanen</button>
</form>
</details>
<form id="builder-filters" method="GET" class="builder-filter">
<label
><span class="sr-only">Fundus durchsuchen</span><input
type="search"
name="q"
value={data.filters.query}
placeholder="Fundus durchsuchen"
/></label
>
<label
><span class="sr-only">Abschnitt filtern</span><select name="category"
><option value="">Alle Abschnitte</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={data.filters.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<button class="text-button" type="submit">Filtern</button>
</form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="builder-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
form="builder-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
<div class="builder-source-list">
{#each visiblePieces as piece (piece.id)}
<article
class="builder-source-card"
class:is-dragging={dragged?.kind === 'piece' && dragged.id === piece.id}
style={`--category:${piece.categories[0]?.color ?? '#8b8f98'}`}
>
<div
class="source-drag-copy"
role="group"
aria-label={`${piece.title} in den Ablauf ziehen`}
draggable="true"
ondragstart={(event) => startDrag('piece', piece.id, event)}
ondragend={finishDrag}
>
<div>
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
><span>{formatClock(piece.durationSeconds)}</span>
</div>
<h3>
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit"
><AppIcon name="star" /></span
>{/if}{piece.title}
</h3>
<p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
</p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
<PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/addUsage"
class="source-add-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="pieceId"
value={piece.id}
/>
<label
><span class="sr-only">Ziel für {piece.title}</span><select
name="sectionId"
aria-label={`Ziel für ${piece.title}`}
><option value="">Reservebank</option
>{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Hinzufügen</button>
</form>
</article>
{/each}
</div>
{#if !visiblePieces.length}<div class="empty-state">
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2>
<p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
markieren.
</p>
</div>{/if}
</section>
<section
class="builder-pane run-pane"
class:mobile-hidden={mobilePane !== 'plan'}
aria-labelledby="run-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
</header>
<div class="run-list">
{#each data.show.sections as section, index (section.id)}
<div
class="section-drag-slot"
role="presentation"
ondragover={(event) => {
event.preventDefault();
event.stopPropagation();
}}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
animate:flip={{ duration: 180 }}
>
{#if dropPreview?.kind === 'section' && dropPreview.position === index}
<div class="drag-insert-preview section-insert-preview" data-drop-preview="section">
<span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div>
{/if}
<article
class="run-section editable-run-section"
class:is-dragging={dragged?.kind === 'section' && dragged.id === section.id}
class:drop-ready={dropTarget === section.id}
style={`--category:${section.categoryColor ?? '#8b8f98'}`}
ondragover={(event) => {
if (dragged?.kind === 'section') previewSection(event, index);
else previewUsage(event, section.id, section.usages.length, false);
}}
ondrop={dropAtPreview}
>
<header
class="section-drag-handle"
role="group"
aria-label={`${section.name} verschieben`}
draggable="true"
ondragstart={(event) => startDrag('section', section.id, event)}
ondragend={finishDrag}
>
<div class="section-number">{String(index + 1).padStart(2, '0')}</div>
<div>
<span class="category-label"><i></i>{section.category ?? 'Offen'}</span>
<h3>{section.name}</h3>
</div>
<strong>{formatClock(section.durationSeconds)}</strong>
</header>
<div class="row-actions" aria-label={`Abschnitt ${section.name} verschieben`}>
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input type="hidden" name="position" value={index - 1} /><button
type="submit"
disabled={index === 0}
aria-label={`${section.name} nach oben`}><AppIcon name="arrow-up" /></button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input type="hidden" name="position" value={index + 1} /><button
type="submit"
disabled={index === data.show.sections.length - 1}
aria-label={`${section.name} nach unten`}><AppIcon name="arrow-down" /></button
>
</form>
</div>
{#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)}
<div
class="usage-drag-slot"
role="presentation"
ondragover={(event) => {
event.preventDefault();
event.stopPropagation();
}}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
animate:flip={{ duration: 180 }}
>
{#if showsUsagePreview(section.id, usageIndex)}
<div
class="drag-insert-preview usage-insert-preview"
data-drop-preview="usage"
>
<strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div>
{/if}
<div
class="usage-row editable-usage"
class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id}
role="group"
aria-label={`Verwendung ${usage.title}`}
ondragover={(event) => previewUsage(event, section.id, usageIndex)}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
>
<div
class="usage-drag-copy"
role="group"
aria-label={`${usage.title} verschieben`}
draggable="true"
ondragstart={(event) => {
event.stopPropagation();
startDrag('usage', usage.id, event);
}}
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)}</small
>
</div>
<div class="row-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="sectionId" value={section.id} /><input
type="hidden"
name="position"
value={usageIndex - 1}
/><button
type="submit"
disabled={usageIndex === 0}
aria-label={`${usage.title} nach oben`}
><AppIcon name="arrow-up" /></button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="sectionId" value={section.id} /><input
type="hidden"
name="position"
value={usageIndex + 1}
/><button
type="submit"
disabled={usageIndex === section.usages.length - 1}
aria-label={`${usage.title} nach unten`}
><AppIcon name="arrow-down" /></button
>
</form>
<UsageEditorDialog
{usage}
revision={data.show.revision}
sectionId={section.id}
sections={data.show.sections}
error={form?.error}
{enhanceCommand}
/>
</div>
</div>
</div>
{/each}
{#if showsUsagePreview(section.id, section.usages.length)}
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div>
{/if}
</div>{:else}
{#if showsUsagePreview(section.id, 0)}
<div class="usage-list">
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div>
</div>
{/if}
<div
class="section-placeholder"
role="group"
aria-label={`Leerer Abschnitt ${section.name}`}
ondragover={(event) => previewUsage(event, section.id, 0, false)}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
>
Leerer Abschnitt · Piece aus dem Fundus hinzufügen oder hierher ziehen
</div>
{/if}
<details class="section-editor">
<summary>Abschnitt bearbeiten</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateSection"
class="section-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><label
>Name<input name="name" required maxlength="200" value={section.name} /></label
><label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={section.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
><label
>Explizite Dauer in Sekunden<input
name="explicitDurationSeconds"
type="number"
min="0"
max="86400"
value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces"
/></label
><button class="secondary-button" type="submit">Abschnitt speichern</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeSection"
onsubmit={(event) =>
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><button class="text-button danger-text" type="submit"
>Abschnitt entfernen</button
>
</form>
</details>
</article>
</div>
{/each}
{#if dropPreview?.kind === 'section' && dropPreview.position === data.show.sections.length}
<div
class="drag-insert-preview section-insert-preview"
data-drop-preview="section"
role="group"
aria-label={`${draggedTitle()} hier einfügen`}
ondragover={(event) => event.preventDefault()}
ondrop={dropAtPreview}
>
<span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div>
{/if}
</div>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/addSection"
class="add-section-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><label
>Name<input name="name" required maxlength="200" placeholder="Neuer Abschnitt" /></label
><label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
><label
>Explizite Dauer <input
name="explicitDurationSeconds"
type="number"
min="0"
max="86400"
placeholder="optional, Sekunden"
/></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form>
<section
class="reserve-box editable-reserve"
class:drop-ready={dropTarget === 'reserve'}
aria-label="Reservebank"
ondragover={(event) => previewUsage(event, null, data.show.reserve.length, false)}
ondrop={dropAtPreview}
>
<header>
<div>
<span aria-hidden="true">◇</span>
<div>
<strong>Reservebank</strong><small
>{data.show.reserve.length}
{data.show.reserve.length === 1 ? 'Piece' : 'Pieces'} · zählt nicht zur Planzeit</small
>
</div>
</div>
</header>
{#each data.show.reserve as usage, usageIndex (usage.id)}
<div
class="reserve-drag-slot"
role="presentation"
ondragover={(event) => {
event.preventDefault();
event.stopPropagation();
}}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
animate:flip={{ duration: 180 }}
>
{#if showsUsagePreview(null, usageIndex)}
<div class="drag-insert-preview reserve-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier in die Reserve einfügen</small>
</div>
{/if}
<div
class="reserve-usage"
class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id}
role="group"
aria-label={`Reserve ${usage.title}`}
ondragover={(event) => previewUsage(event, null, usageIndex)}
ondrop={(event) => {
event.stopPropagation();
dropAtPreview();
}}
>
<div
class="usage-drag-copy"
role="group"
aria-label={`${usage.title} verschieben`}
draggable="true"
ondragstart={(event) => startDrag('usage', usage.id, event)}
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" /><label
><span class="sr-only">{usage.title} einplanen in</span><select
name="sectionId"
required
><option value="">Abschnitt wählen</option
>{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Einplanen</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><button
class="text-button danger-text"
type="submit"
aria-label={`${usage.title} aus Sendung entfernen`}>Aus Sendung entfernen</button
>
</form>
<UsageEditorDialog
{usage}
revision={data.show.revision}
sectionId=""
sections={data.show.sections}
error={form?.error}
{enhanceCommand}
/>
</div>
</div>
{/each}
{#if showsUsagePreview(null, data.show.reserve.length)}
<div class="drag-insert-preview reserve-insert-preview" data-drop-preview="usage">
<strong>{draggedTitle()}</strong><small>Hier in die Reserve einfügen</small>
</div>
{/if}
{#if !data.show.reserve.length}<p>
Pieces lassen sich hier parken, ohne die geplante Dauer zu erhöhen.
</p>{/if}
</section>
</section>
</div>
<form
bind:this={moveSectionForm}
use:enhance={enhanceCommand}
method="POST"
action="?/moveSection"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={draggedSectionId}
/><input type="hidden" name="position" value={dropPosition} />
</form>
<form
bind:this={moveUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/moveUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={draggedUsageId}
/><input type="hidden" name="sectionId" value={dropSectionId} /><input
type="hidden"
name="position"
value={dropPosition}
/>
</form>
<form
bind:this={addUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/addUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="pieceId"
value={draggedPieceId}
/><input type="hidden" name="sectionId" value={dropSectionId} />
</form>
<form
bind:this={removeUsageForm}
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
class="hidden-command-form"
>
<input type="hidden" name="revision" value={data.show.revision} />
<input type="hidden" name="usageId" value={removedUsageId} />
</form>
</div>
+47
View File
@@ -0,0 +1,47 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
createFirstAdmin,
createSession,
hasUsers,
setupIsConfigured,
validDisplayName,
validEmail,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = () => {
if (hasUsers()) redirect(303, '/login');
return { configured: setupIsConfigured() };
};
export const actions = {
default: async ({ request, cookies }) => {
if (hasUsers()) redirect(303, '/login');
const data = await request.formData();
const setupToken = String(data.get('setupToken') || '');
const displayName = String(data.get('displayName') || '').trim();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
if (!validDisplayName(displayName) || !validEmail(email) || !validPassword(password)) {
return fail(400, {
error: 'Bitte Name, gültige E-Mail und ein Passwort mit mindestens 12 Zeichen prüfen.',
displayName,
email
});
}
try {
const user = await createFirstAdmin({ setupToken, displayName, email, password });
createSession(user.id, cookies);
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'SETUP_CLOSED') redirect(303, '/login');
return fail(403, {
error: 'Das Betreiber-Setup-Token ist ungültig.',
displayName,
email
});
}
redirect(303, '/fundus');
}
} satisfies Actions;
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Erstadmin einrichten | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Einmalige Einrichtung</p>
<h1>Ersten Barkeeper anlegen</h1>
<p>
Dieses Konto verwaltet Einladungen, Rollen und Sperren. Danach wird die Einrichtung geschlossen.
</p>
{#if !data.configured}
<div class="form-message error" role="alert">
Vor dem Start muss ein mindestens 32 Zeichen langes <code>SENDEWERK_SETUP_TOKEN</code>
konfiguriert werden.
</div>
{:else}
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Betreiber-Setup-Token<input
name="setupToken"
type="password"
required
autocomplete="off"
/></label
>
<label
>Name<input
name="displayName"
required
maxlength="80"
value={form?.displayName ?? ''}
autocomplete="name"
/></label
>
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<p class="field-hint">
Mindestens 12 Zeichen. Das Passwort wird ausschließlich als Argon2id-Hash gespeichert.
</p>
<button class="primary-button" type="submit">Barkeeper anlegen</button>
</form>
{/if}
</section>
+198 -3
View File
@@ -1,5 +1,200 @@
import { getTaxonomy } from '#lib/server/previews.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
createTaxonomy,
deleteTaxonomy,
getTaxonomy,
updateTaxonomy,
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import {
inviteUser,
isRole,
listOpenInvitations,
listUsers,
requireBarkeeper,
revokeInvitation,
setUserLocked,
setUserRole,
validEmail
} from '#lib/server/auth.js';
export function load() {
return { taxonomy: getTaxonomy() };
export const load: PageServerLoad = ({ locals }) => {
requireBarkeeper(locals.user);
return {
taxonomy: getTaxonomy(),
users: listUsers(),
invitations: listOpenInvitations(),
tools: listTools(true),
mailTransport: getMailConfig().transport
};
};
function actionError(cause: unknown): string {
const code = cause instanceof Error ? cause.message : '';
if (code === 'USER_EXISTS') return 'Für diese E-Mail existiert bereits ein Konto.';
if (code === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.';
if (code === 'LAST_BARKEEPER') return 'Mindestens ein aktiver Barkeeper muss erhalten bleiben.';
if (code === 'NOT_FOUND') return 'Der Eintrag wurde nicht gefunden.';
if (code === 'TAXONOMY_EXISTS') return 'Dieser Name ist bereits vorhanden.';
if (code === 'TAXONOMY_IN_USE')
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
if (code === 'INVALID_ICON') return 'Bitte ein Icon aus der angebotenen Auswahl verwenden.';
if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.';
if (code === 'INVALID_TOOL_URL')
return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.';
if (code === 'INVALID_TOOL_FORMAT')
return 'Mindestens ein gewähltes Format existiert nicht mehr.';
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
function toolInput(data: FormData): ToolInput {
return {
name: String(data.get('name') || ''),
startUrl: String(data.get('startUrl') || ''),
description: String(data.get('description') || ''),
iconName: String(data.get('iconName') || ''),
isActive: data.get('isActive') === '1',
formatIds: data.getAll('formatIds').map(String)
};
}
export const actions = {
invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const role = String(data.get('role') || '');
if (!validEmail(email) || !isRole(role)) {
return fail(400, { intent: 'invite', email, error: 'E-Mail und Rolle bitte prüfen.' });
}
try {
const transport = await inviteUser(actor, email, role);
return {
intent: 'invite',
success:
transport === 'local'
? 'Einladung wurde in der lokalen Mail-Outbox abgelegt.'
: 'Einladung wurde versandt.'
};
} catch (cause) {
return fail(400, { intent: 'invite', email, error: actionError(cause) });
}
},
role: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
const userId = String(data.get('userId') || '');
const role = String(data.get('role') || '');
if (!userId || !isRole(role)) return fail(400, { intent: 'role', error: 'Ungültige Rolle.' });
try {
setUserRole(actor, userId, role);
return { intent: 'role', success: 'Rolle aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'role', error: actionError(cause) });
}
},
lock: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
setUserLocked(actor, String(data.get('userId') || ''), true);
return { intent: 'lock', success: 'Konto gesperrt und bestehende Sitzungen beendet.' };
} catch (cause) {
return fail(400, { intent: 'lock', error: actionError(cause) });
}
},
unlock: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
setUserLocked(actor, String(data.get('userId') || ''), false);
return { intent: 'unlock', success: 'Konto entsperrt.' };
} catch (cause) {
return fail(400, { intent: 'unlock', error: actionError(cause) });
}
},
revokeInvitation: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
revokeInvitation(actor, String(data.get('invitationId') || ''));
return { intent: 'revokeInvitation', success: 'Einladung widerrufen.' };
},
createTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
createTaxonomy(
kind,
String(data.get('name') || ''),
String(data.get('color') || ''),
String(data.get('iconName') || '')
);
return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
},
updateTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
updateTaxonomy(
kind,
String(data.get('taxonomyId') || ''),
String(data.get('name') || ''),
String(data.get('iconName') || '')
);
return { intent: 'taxonomy', success: 'Eintrag aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
},
deleteTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
deleteTaxonomy(kind, String(data.get('taxonomyId') || ''));
return { intent: 'taxonomy', success: 'Eintrag entfernt.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
},
createTool: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
createTool(toolInput(data));
return { intent: 'tool', success: 'Werkzeug angelegt.' };
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
},
updateTool: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
updateTool(String(data.get('toolId') || ''), toolInput(data));
return { intent: 'tool', success: 'Werkzeug aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
}
} satisfies Actions;
+234 -11
View File
@@ -1,6 +1,14 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
}
</script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
@@ -8,32 +16,247 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Verwaltung</h1>
<p class="page-intro">Abschnittskategorien, Formate und Themen im Überblick.</p>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section class="quiet-panel account-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Konten & Rollen</p>
<h2>Redaktionszugänge</h2>
</div>
<span class="mail-mode"
>Mail: {data.mailTransport === 'local' ? 'lokale Outbox' : 'SMTP'}</span
>
</div>
<div class="account-list">
{#each data.users as user (user.id)}
<article class="account-row">
<div><strong>{user.displayName}</strong><small>{user.email}</small></div>
<span class:locked={user.lockedAtUtc} class="account-status"
>{user.lockedAtUtc ? 'Gesperrt' : 'Aktiv'}</span
>
<form method="POST" action="?/role" class="inline-account-form">
<input type="hidden" name="userId" value={user.id} />
<label class="sr-only" for={`role-${user.id}`}>Rolle für {user.displayName}</label>
<select id={`role-${user.id}`} name="role">
<option value="barkeeper" selected={user.role === 'barkeeper'}>Barkeeper</option>
<option value="thekencrew" selected={user.role === 'thekencrew'}>Thekencrew</option>
</select>
<button class="text-button" type="submit">Rolle speichern</button>
</form>
<form method="POST" action={user.lockedAtUtc ? '?/unlock' : '?/lock'}>
<input type="hidden" name="userId" value={user.id} />
<button
class="secondary-button"
type="submit"
disabled={user.id === data.user?.id}
title={user.id === data.user?.id
? 'Das eigene Konto kann nicht gesperrt werden.'
: undefined}
>{user.lockedAtUtc
? 'Entsperren'
: user.id === data.user?.id
? 'Eigenes Konto'
: 'Sperren'}</button
>
</form>
</article>
{/each}
</div>
</section>
<div class="management-grid">
<section class="quiet-panel invite-panel">
<p class="eyebrow">Einladung</p>
<h2>Neues Konto einladen</h2>
<form class="compact-form" method="POST" action="?/invite">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.intent === 'invite' ? (form.email ?? '') : ''}
/></label
>
<label
>Rolle<select name="role"
><option value="thekencrew">Thekencrew</option><option value="barkeeper"
>Barkeeper</option
></select
></label
>
<button class="primary-button" type="submit">Einladung erstellen</button>
</form>
</section>
<section class="quiet-panel invite-panel">
<p class="eyebrow">Offene Einladungen</p>
<h2>Noch nicht angenommen</h2>
{#if data.invitations.length}
<div class="invitation-list">
{#each data.invitations as invitation (invitation.id)}
<div>
<span
><strong>{invitation.email}</strong><small
>{invitation.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small
></span
>
<form method="POST" action="?/revokeInvitation">
<input type="hidden" name="invitationId" value={invitation.id} /><button
class="text-button"
type="submit">Widerrufen</button
>
</form>
</div>
{/each}
</div>
{:else}<p class="quiet-copy">Keine offenen Einladungen.</p>{/if}
</section>
</div>
<h2 class="section-heading">Werkzeuge</h2>
<section class="quiet-panel tool-management">
<div class="panel-heading">
<div>
<p class="eyebrow">Redaktionelle Hilfsmittel</p>
<h2>Werkzeugkatalog</h2>
</div>
<button class="compact-add-button" type="button" onclick={() => toolDialog.showModal()}>
<AppIcon name="plus" /> Werkzeug hinzufügen
</button>
</div>
<p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
</p>
<div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)}
<button
type="button"
class:inactive={!tool.isActive}
aria-label={`${tool.name} bearbeiten`}
onclick={() => toolDialog.showModal(tool)}
>
<span class="catalog-entry-icon"><AppIcon name={tool.iconName || 'wrench'} /></span>
<span class="catalog-entry-copy">
<strong>{tool.name}</strong>
<small
>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}{#if tool.formatIds.length}
· {data.taxonomy.formats
.filter((format) => tool.formatIds.includes(format.id))
.map((format) => format.name)
.join(', ')}{/if}</small
>
</span>
<AppIcon name="pen" />
</button>
{/each}
</div>
</section>
<ToolEditorDialog bind:this={toolDialog} formats={data.taxonomy.formats} />
<h2 class="section-heading">Taxonomie</h2>
<div class="settings-grid">
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2>
<div class="taxonomy-list">
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.categories as category (category.id)}<span
><i style={`--tax-color: ${category.color}`}></i>{category.name}</span
><i style={`--tax-color: ${category.color}`}></i>{category.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="category" /><input
type="hidden"
name="taxonomyId"
value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="category" /><label
><span class="sr-only">Neue Abschnittskategorie</span><input
name="name"
required
maxlength="80"
placeholder="Neue Kategorie"
/></label
><label class="color-field"
><span class="sr-only">Farbe</span><input
name="color"
type="color"
value="#67DDE8"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2>
<div class="taxonomy-list">
{#each data.taxonomy.formats as format (format.id)}<span>{format.name}</span>{/each}
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.formats as format (format.id)}<span
>{format.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="format" /><input
type="hidden"
name="taxonomyId"
value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="format" /><label
><span class="sr-only">Neues Format</span><input
name="name"
required
maxlength="80"
placeholder="Neues Format"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<section class="quiet-panel taxonomy-panel topic-management-panel">
<p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list">
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
<div class="topic-settings-list">
{#each data.taxonomy.topics as topic (topic.id)}
<details>
<summary
><AppIcon name={topic.iconName || 'circle'} /><strong>{topic.name}</strong><AppIcon
name="pen"
/></summary
>
<form class="topic-edit-form" method="POST" action="?/updateTaxonomy">
<input type="hidden" name="kind" value="topic" />
<input type="hidden" name="taxonomyId" value={topic.id} />
<label>Name<input name="name" required maxlength="80" value={topic.name} /></label>
<IconPicker name={topic.iconName} />
<button class="secondary-button" type="submit">Änderungen speichern</button>
</form>
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" />
<input type="hidden" name="taxonomyId" value={topic.id} />
<button class="text-button danger-text" type="submit">Thema löschen</button>
</form>
</details>
{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label
><span class="sr-only">Neues Thema</span><input
name="name"
required
maxlength="80"
placeholder="Neues Thema"
/></label
><IconPicker name="" /><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
</div>
<p class="settings-footnote">Konten und Rollen sind noch nicht verfügbar.</p>
</div>
+10 -1
View File
@@ -2,11 +2,20 @@
set -eu
mkdir -p /app/data/uploads
mkdir -p /app/data/uploads /app/data/mail-outbox
chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}"
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-6M}"
export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}"
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}"
export HOST_HEADER="${HOST_HEADER:-x-forwarded-host}"
export ADDRESS_HEADER="${ADDRESS_HEADER:-x-forwarded-for}"
export XFF_DEPTH="${XFF_DEPTH:-1}"
fi
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-3000}"
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 3.7 MiB

Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more