4 Commits
Author SHA1 Message Date
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
hnzio 7a49e66c37 Release 0.1.1 2026-10-21 16:38:56 +02:00
64 changed files with 9974 additions and 781 deletions

No files matched your search

+17
View File
@@ -1,3 +1,20 @@
# Relative paths are resolved from the project directory. # Relative paths are resolved from the project directory.
SENDEWERK_DB_PATH=.data/sendewerk.sqlite SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads 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", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.1.0", "version": "0.4.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+86 -13
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten Etappe 1 bis 4: technisches Grundgerüst, Anmeldung und Rollen, einen
und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte persistenten Fundus sowie den vollständigen Sendungsbaukasten. Live-Steuerung
Live-Steuerung folgen in späteren Etappen. und Präsentationsausgabe folgen in späteren Etappen.
## Voraussetzungen ## Voraussetzungen
@@ -20,6 +20,9 @@ nicht erforderlich.
```sh ```sh
npm ci npm ci
cp .env.example .env 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 setup
npm run dev npm run dev
``` ```
@@ -29,18 +32,72 @@ erstellt die konfigurierten Verzeichnisse, führt ausstehende Migrationen aus
und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft
ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten. 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 ## Konfiguration
| Variable | Vorgabe | Bedeutung | | Variable | Vorgabe | Bedeutung |
| ---------------------- | ------------------------ | ----------------------------------- | | --------------------------- | ------------------------ | ---------------------------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei | | `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis | | `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers | | `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
| `PORT` | `3000` | Port des Produktionsservers | | `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 Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`. 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.
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.
## 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. 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.
## Befehle ## Befehle
| Befehl | Zweck | | Befehl | Zweck |
@@ -48,7 +105,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
| `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung | | `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung |
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen | | `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
| `npm run db:seed` | Reproduzierbare Beispieldaten laden | | `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 check` | Svelte- und TypeScript-Prüfung |
| `npm run lint` | Formatierung und ESLint prüfen | | `npm run lint` | Formatierung und ESLint prüfen |
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank | | `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
@@ -61,6 +118,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
```sh ```sh
npm ci npm ci
npm run setup npm run setup
export SENDEWERK_APP_ORIGIN=http://localhost:3000
npm run build npm run build
HOST=127.0.0.1 PORT=3000 npm start HOST=127.0.0.1 PORT=3000 npm start
``` ```
@@ -68,6 +126,12 @@ HOST=127.0.0.1 PORT=3000 npm start
Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den
Datenbankzugriff. 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: Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert:
```sh ```sh
@@ -82,9 +146,17 @@ Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und
```sh ```sh
docker build -t sendewerk:local . 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, Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden. abschließend geprüft werden.
@@ -101,5 +173,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter `better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind
Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum. MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen
unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
+97 -13
View File
@@ -2,18 +2,19 @@
## Etappe 1 ## Etappe 1
| Kriterium | Nachweis | Stand | | Kriterium | Nachweis | Stand |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------- | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 | | Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 | | Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 | | Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
| Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 | | Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 |
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 | | Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 | | Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 | | Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 4 Playwright-Abläufe; 1440x1000, 1280x900 und 390x844; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 | | Desktop und Smartphone ohne horizontales Scrollen | 8 Playwright-Abläufe bei 390, 768, 960, 1280 und 1440 Pixeln; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen | | Kompakte Arbeitsoberfläche in beiden Farbmodi | 390, 768 und 1440 Pixel jeweils in Light/Dark; einspaltige Mobilkarten, lange Titel, Metadatenumbrüche und Leerzustände | erfüllt am 21.10.2026 |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang | | Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
## Ausgeführte Prüfungen ## Ausgeführte Prüfungen
@@ -23,7 +24,9 @@
- `npm run lint`: erfolgreich - `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen - `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` - `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 4 von 4 Browsertests erfolgreich - `npm run test:e2e`: 8 von 8 Browsertests erfolgreich; 390, 768 und 1440 Pixel
jeweils in Light- und Darkmode sowie Tastaturbedienung, feste Navigation,
einspaltige Funduskarten, lange Inhalte und Überläufe geprüft
- Zwei getrennte `npm start`-Läufe: Healthcheck und persistente Daten erfolgreich - Zwei getrennte `npm start`-Läufe: Healthcheck und persistente Daten erfolgreich
- `bash -n start.sh` und programmatische Manifest-Kernfeldprüfung: erfolgreich - `bash -n start.sh` und programmatische Manifest-Kernfeldprüfung: erfolgreich
@@ -36,3 +39,84 @@
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale - Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
Python-Modul `playwright` nicht installiert war. Die projektgebundene Python-Modul `playwright` nicht installiert war. Die projektgebundene
TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich. 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
+115 -25
View File
@@ -31,23 +31,23 @@ spätere API dieselben Regeln verwenden.
- Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative - Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative
ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`. ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`.
## Geplantes Gesamtdatenmodell ## Gesamtdatenmodell
Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten Die ersten vier Etappen materialisieren Identität, Inhaltsverwaltung und
Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt. Sendungsplanung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens, Sperren und technische Zugänge; Tokens nur gehasht | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | | Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten | | Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz | | Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | | Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | | Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | | 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 | | RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | | API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | | Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel ### Historien- und Kopierregel
@@ -63,22 +63,112 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt. getrennt.
## 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.
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.
## 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 Präsentation aus dem sendefertigen Piece kopiert. Eigene Notizen
liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für
Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt.
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 Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. 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.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Auf schmalen Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
Ansichten werden sie über eine gut erreichbare Umschaltung einzeln gezeigt. ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
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 Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht. Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht.
Etappe 1 zeigt Fundus, Sendungsbau und Live-Modus als sichtbare Fundus und Sendungsbau sind produktive Arbeitsansichten. Der Live-Modus bleibt
`Gestaltungsvorschau`. Die Daten kommen bereits aus SQLite, mutierende bis Etappe 5 eine sichtbare Gestaltungsvorschau, kann aber bereits gezielt die
Fachabläufe werden jedoch nicht vorgetäuscht. gewählte Sendung und deren kopierte Texte lesen.
## Sicherheitsgrenzen ab den Folgeetappen ## Sicherheitsgrenzen ab den Folgeetappen
Authentifizierung und Autorisierung werden serverseitig an jeder Route und Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt. betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen. kontrollierte Redirects.
+121
View File
@@ -81,3 +81,124 @@ Vorschauseiten lesen echte persistente SQLite-Seed-Daten, bieten aber keine
funktionslosen Speichern- oder Live-Schaltflächen. Ein sichtbarer Hinweis funktionslosen Speichern- oder Live-Schaltflächen. Ein sichtbarer Hinweis
grenzt Gestaltungsvorschauen von den späteren Fachfunktionen ab. Damit wird grenzt Gestaltungsvorschauen von den späteren Fachfunktionen ab. Damit wird
keine noch nicht implementierte Funktion als fertig dargestellt. keine noch nicht implementierte Funktion als fertig dargestellt.
## D-007: Arbeitsansicht nach verfügbarem Platz
Die feste Desktop-Seitenleiste wird bis `52rem` verwendet und dann vollständig
durch mobile Kopf- und Fußnavigation ersetzt. Der Sendungsbau schaltet bereits
bis `72rem` von zwei Spalten auf eine bedienbare Pane-Umschaltung um. Dadurch
bleibt die Desktopnavigation in schmalen Fenstern erhalten, ohne Fundus und
Ablauf zusammenzudrücken.
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>
+70 -7
View File
@@ -2,8 +2,9 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und Etappe 1 bis Etappe 4 sind abgeschlossen. Architektur, Gesamtdatenmodell,
lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen. Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung und lokaler
Betriebsweg sind dokumentiert. Etappe 5 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1 ## Umgesetzter Stand Etappe 1
@@ -13,6 +14,8 @@ lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
- persistentes Light-/Dark-/System-Theme - persistentes Light-/Dark-/System-Theme
- responsive Navigation und gekennzeichnete Vorschauen für Fundus, - responsive Navigation und gekennzeichnete Vorschauen für Fundus,
Sendungsbau und Live-Modus Sendungsbau und Live-Modus
- kompakte Desktop-Arbeitsansicht mit fester Seitenleiste, Fundusliste und
zweigeteiltem Sendungsbau; auf schmalen Ansichten Umschaltung statt Engpass
- Healthcheck, Produktionsstart und frühes Cloudron-Paket - Healthcheck, Produktionsstart und frühes Cloudron-Paket
- getrennte Unit-/Integrations- und Browser-Testdaten - getrennte Unit-/Integrations- und Browser-Testdaten
@@ -20,12 +23,72 @@ Die Vorschauen lesen echte Daten aus SQLite. Suche und mobile Pane-Umschaltung
funktionieren lokal; redaktionelle Schreiboperationen bleiben bewusst den funktionieren lokal; redaktionelle Schreiboperationen bleiben bewusst den
späteren Etappen vorbehalten. späteren Etappen vorbehalten.
## Nicht Bestandteil dieser Etappe Die abschließende Oberflächenüberarbeitung von Etappe 1 reduziert große
Seitenköpfe und verschachtelte Karten, bündelt den Schreibschutzhinweis
aufklappbar und hält Entwicklungsinformationen aus der Arbeitsoberfläche heraus.
Eingang und Archiv zeigen kurze Leerzustände, die Verwaltung trennt
Abschnittskategorien, Formate und Themen kompakt.
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Die abschließende responsive Prüfung verdichtet Seitenleiste, Navigation und
Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API. Funduszeilen weiter. Mobile Funduskarten sind intern einspaltig; lange Titel,
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
werden. Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen.
## Noch nicht umgesetzt
Echte Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und
API. Die verbleibenden Vorschauen dürfen nicht als
Implementierung dieser späteren Funktionen verstanden werden.
## 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
## 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
- 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
- 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
## Bekannte offene Betriebsprüfung ## Bekannte offene Betriebsprüfung
+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);
+537 -10
View File
@@ -1,14 +1,19 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.1.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.1.0", "version": "0.4.0",
"dependencies": { "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",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -18,6 +23,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13", "@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.19.0",
@@ -35,6 +41,16 @@
"node": ">=22.17 <25" "node": ">=22.17 <25"
} }
}, },
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cacheable/memory": { "node_modules/@cacheable/memory": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -327,6 +343,238 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@node-rs/argon2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@node-rs/argon2-android-arm-eabi": "2.2.2",
"@node-rs/argon2-android-arm64": "2.2.2",
"@node-rs/argon2-darwin-arm64": "2.2.2",
"@node-rs/argon2-darwin-x64": "2.2.2",
"@node-rs/argon2-freebsd-x64": "2.2.2",
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
"@node-rs/argon2-linux-x64-musl": "2.2.2",
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
}
},
"node_modules/@node-rs/argon2-android-arm-eabi": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-android-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-freebsd-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-x64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.154.0", "version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -717,6 +965,29 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@tokenizer/inflate": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
"integrity": "sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -776,6 +1047,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/sanitize-html": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.2.tgz",
"integrity": "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^12.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.72.0", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
@@ -1241,11 +1522,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -1293,6 +1579,85 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1304,7 +1669,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -1625,6 +1989,24 @@
"flat-cache": "^6.1.23" "flat-cache": "^6.1.23"
} }
}, },
"node_modules/file-type": {
"version": "22.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.2.0.tgz",
"integrity": "sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.5.1",
"strtok3": "^10.5.0",
"token-types": "^6.1.2",
"uint8array-extras": "^1.6.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -1722,6 +2104,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1765,6 +2189,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1813,6 +2246,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/launder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.23"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2131,6 +2573,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0" "@jridgewell/sourcemap-codec": "^1.6.0"
} }
}, },
"node_modules/marked": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
"integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2171,14 +2625,12 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.20", "version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -2209,6 +2661,15 @@
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/obug": { "node_modules/obug": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
@@ -2273,6 +2734,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2297,7 +2764,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@@ -2346,7 +2812,6 @@
"version": "8.5.31", "version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2610,6 +3075,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/sanitize-html": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.1.0",
"launder": "^1.7.2",
"parse-srcset": "^1.0.2",
"postcss": "^8.5.27"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -2665,7 +3147,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -2678,6 +3159,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/strtok3": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
"integrity": "sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.57.2", "version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -2856,6 +3353,24 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -2930,6 +3445,18 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/uint8array-extras": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz",
"integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+11 -5
View File
@@ -1,15 +1,15 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.1.0", "version": "0.4.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
}, },
"scripts": { "scripts": {
"dev": "vite dev", "dev": "npm run db:migrate && node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js dev",
"build": "vite build", "build": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js build",
"preview": "vite preview", "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", "start": "npm run db:migrate && node --env-file-if-exists=.env build",
"setup": "npm run db:migrate && npm run db:seed", "setup": "npm run db:migrate && npm run db:seed",
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs", "db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
@@ -24,7 +24,12 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "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",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -34,6 +39,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13", "@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0", "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 { tmpdir } from 'node:os';
import path from 'node:path'; 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({ export default defineConfig({
testDir: 'tests', testDir: 'tests',
@@ -18,6 +22,11 @@ export default defineConfig({
env: { env: {
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'), SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'), 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', HOST: '127.0.0.1',
PORT: '4173' PORT: '4173'
} }
+160 -9
View File
@@ -124,9 +124,49 @@ export function seed(databasePath = getPaths().database) {
'Pixel als bewusste Entscheidung.', 'Pixel als bewusste Entscheidung.',
timestamp, timestamp,
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-%'); database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%');
const addTopic = database.prepare( const addTopic = database.prepare(
@@ -137,7 +177,9 @@ export function seed(databasePath = getPaths().database) {
['piece-effects', 'topic-film'], ['piece-effects', 'topic-film'],
['piece-quiz', 'topic-gaming'], ['piece-quiz', 'topic-gaming'],
['piece-pixels', '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); addTopic.run(...values);
@@ -149,7 +191,9 @@ export function seed(databasePath = getPaths().database) {
['piece-console', 'cat-jc'], ['piece-console', 'cat-jc'],
['piece-effects', 'cat-reaction'], ['piece-effects', 'cat-reaction'],
['piece-quiz', 'cat-jc'], ['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); addCategory.run(...values);
@@ -173,7 +217,47 @@ export function seed(databasePath = getPaths().database) {
95, 95,
0 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', 'twitch_title',
'announcement_text', 'announcement_text',
'status', 'status',
'revision',
'created_at_utc', 'created_at_utc',
'updated_at_utc' 'updated_at_utc'
], ],
@@ -197,6 +282,18 @@ export function seed(databasePath = getPaths().database) {
'Kleine Welten, große Ideen | heinzlive', 'Kleine Welten, große Ideen | heinzlive',
'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.', 'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.',
'ready', '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,
timestamp timestamp
] ]
@@ -205,11 +302,25 @@ export function seed(databasePath = getPaths().database) {
replace( replace(
'show_sections', 'show_sections',
['id', 'show_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[ [
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300], 'id',
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null], 'show_id',
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600] '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', 'duration_seconds',
'title_snapshot', 'title_snapshot',
'intro_snapshot', 'intro_snapshot',
'research_snapshot',
'moderation_snapshot', 'moderation_snapshot',
'transition_snapshot', 'transition_snapshot',
'presentation_snapshot', 'presentation_snapshot',
'own_notes',
'is_reserve' 'is_reserve'
], ],
[ [
@@ -239,9 +352,11 @@ export function seed(databasePath = getPaths().database) {
480, 480,
'Die kleine Konsole mit dem großen Dreh', 'Die kleine Konsole mit dem großen Dreh',
'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?', 'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?',
'## 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.', 'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.',
'Von der kleinen Konsole geht es zur ganz großen Leinwand.', 'Von der kleinen Konsole geht es zur ganz großen Leinwand.',
'Weniger Leistung, mehr Idee.', 'Weniger Leistung, mehr Idee.',
'',
0 0
], ],
[ [
@@ -253,24 +368,60 @@ export function seed(databasePath = getPaths().database) {
720, 720,
'Warum praktische Effekte wieder auffallen', 'Warum praktische Effekte wieder auffallen',
'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.', 'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.',
'',
'Beispiele vergleichen, nicht analog gegen digital ausspielen.', 'Beispiele vergleichen, nicht analog gegen digital ausspielen.',
'Nach so viel Handarbeit wird es spielerisch.', 'Nach so viel Handarbeit wird es spielerisch.',
'Wenn das Licht wirklich auf ein Modell fällt.', 'Wenn das Licht wirklich auf ein Modell fällt.',
'',
0 0
], ],
[ [
'usage-reserve', 'usage-reserve',
'section-stories', 'reserve-bank-show-october',
'piece-pixels', 'piece-pixels',
2, 0,
'open', 'open',
540, 540,
'Pixelart, die nicht nostalgisch sein will', 'Pixelart, die nicht nostalgisch sein will',
'Pixel sind längst keine technische Notlösung mehr.', 'Pixel sind längst keine technische Notlösung mehr.',
'',
'Großaufnahmen vorbereiten, drei Stilmittel benennen.', 'Großaufnahmen vorbereiten, drei Stilmittel benennen.',
'Genug Theorie, jetzt spielen wir selbst.', 'Genug Theorie, jetzt spielen wir selbst.',
'Pixel als bewusste Entscheidung.', 'Pixel als bewusste Entscheidung.',
'Ersatz, falls ein Spiel technisch ausfällt.',
1 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
] ]
] ]
); );
+1801 -236
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -1,5 +1,14 @@
import type { AuthUser } from '#lib/server/auth.js';
declare global { declare global {
namespace App {} namespace App {
interface Locals {
user: AuthUser | null;
}
interface PageData {
user?: AuthUser | null;
}
}
} }
export {}; export {};
+32
View File
@@ -0,0 +1,32 @@
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 token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
if (!publicPaths.has(event.url.pathname) && !event.locals.user) {
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 (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store');
}
return response;
};
@@ -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>
+10 -9
View File
@@ -2,12 +2,13 @@
let { area }: { area: string } = $props(); let { area }: { area: string } = $props();
</script> </script>
<aside class="preview-notice" aria-label="Hinweis zur Gestaltungsvorschau"> <details class="preview-notice">
<span class="preview-dot" aria-hidden="true"></span> <summary>
<div> <span class="preview-dot" aria-hidden="true"></span>
<strong>Gestaltungsvorschau: {area}</strong> <strong>Vorschau · {area}</strong>
<p> <span class="preview-chevron" aria-hidden="true">⌄</span>
In Etappe 1 schreibgeschützt. Die gezeigten Inhalte stammen aus der lokalen SQLite-Datenbank. </summary>
</p> <p>
</div> Diese Ansicht ist schreibgeschützt. Bearbeiten und Speichern folgen in einer späteren Etappe.
</aside> </p>
</details>
+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() { export function getServerPaths() {
return { return {
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'), 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 || ''
}; };
} }
+169
View File
@@ -0,0 +1,169 @@
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,
listPieces,
savePiece,
setPieceArchived,
uploadPieceImage,
type PieceInput
} from './content.js';
import { closeDatabase } 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',
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',
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('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('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
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'
);
await uploadPieceImage(
id,
1,
new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel'
);
const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
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');
await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0);
});
});
+803
View File
@@ -0,0 +1,803 @@
import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number];
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = {
label: string;
url: string;
timecodeSeconds: number | null;
};
export type PieceInput = {
title: string;
summary: string;
formatId: string | null;
status: EditablePieceStatus;
durationSeconds: number;
intro: string;
researchText: string;
moderationNotes: string;
transitionText: string;
presentationText: string;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
};
export type PieceListItem = {
id: string;
title: string;
summary: string;
status: PieceStatus;
revision: number;
durationSeconds: number;
formatId: string | null;
format: string;
categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[];
sourceCount: number;
mediaId: string | null;
};
export type PieceEditor = Omit<PieceInput, 'status'> & {
id: string;
status: PieceStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
media: PieceMedia[];
};
export type PieceMedia = {
id: string;
originalName: string;
mimeType: string;
byteSize: number;
altText: string;
position: number;
};
export type Taxonomy = {
categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[];
topics: { id: string; name: string }[];
};
export const pieceTemplates = [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.'
},
{
id: 'talk',
name: 'Gespräch',
description: 'Leitfragen, Recherche und eine klare Überleitung.'
},
{
id: 'find',
name: 'Reaction / Fundstück',
description: 'Quelle, Einordnung und präsentierbare Kernaussage.'
},
{
id: 'interaction',
name: 'Interaktion',
description: 'Quiz, Publikumsfrage oder kleines Spiel.'
}
] as const;
export type PieceTemplateId = (typeof pieceTemplates)[number]['id'];
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['image/webp', 'webp'],
['image/gif', 'gif']
]);
function cleanText(value: unknown, max: number, field: string): string {
const text = String(value ?? '').trim();
if (text.length > max) throw new Error(`INVALID_${field.toUpperCase()}`);
return text;
}
function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function validUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', ');
const count = getDatabase()
.prepare(`SELECT count(*) AS count FROM ${table} WHERE id IN (${placeholders})`)
.get(...ids) as { count: number };
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
}
function validatePieceInput(input: PieceInput): PieceInput {
const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
if (
!Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 ||
input.durationSeconds > 86_400
) {
throw new Error('INVALID_DURATION');
}
const topicIds = uniqueIds(input.topicIds);
const categoryIds = uniqueIds(input.categoryIds);
if (topicIds.length > 20 || categoryIds.length > 20) throw new Error('INVALID_TAXONOMY');
if (input.formatId) requireTaxonomyIds('formats', [input.formatId]);
requireTaxonomyIds('topics', topicIds);
requireTaxonomyIds('section_categories', categoryIds);
if (input.sources.length > 20) throw new Error('INVALID_SOURCES');
const sources = input.sources.map((source) => {
const label = cleanText(source.label, 200, 'source');
const url = cleanText(source.url, 2_000, 'source');
if (!label || !validUrl(url)) throw new Error('INVALID_SOURCES');
if (
source.timecodeSeconds !== null &&
(!Number.isInteger(source.timecodeSeconds) ||
source.timecodeSeconds < 0 ||
source.timecodeSeconds > 86_400)
) {
throw new Error('INVALID_SOURCES');
}
return { label, url, timecodeSeconds: source.timecodeSeconds };
});
return {
title,
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'),
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
topicIds,
categoryIds,
sources
};
}
export function getTaxonomy(): Taxonomy {
const database = getDatabase();
return {
categories: database
.prepare('SELECT id, name, color FROM section_categories ORDER BY sort_order, name')
.all() as Taxonomy['categories'],
formats: database
.prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'],
topics: database
.prepare('SELECT id, name FROM topics ORDER BY name')
.all() as Taxonomy['topics']
};
}
type PieceListRow = {
id: string;
title: string;
summary: string;
status: PieceStatus;
revision: number;
duration_seconds: number;
format_id: string | null;
format: string;
source_count: number;
media_id: string | null;
};
export function listPieces(filters: {
statuses: PieceStatus[];
query?: string;
formatId?: string;
topicId?: string;
categoryId?: string;
}): PieceListItem[] {
if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`];
const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase();
if (query) {
const escapedQuery = query
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
conditions.push(`(
lower(p.title) LIKE ? ESCAPE '\\' OR lower(p.summary) LIKE ? ESCAPE '\\' OR
EXISTS (SELECT 1 FROM piece_sources ps WHERE ps.piece_id = p.id AND (lower(ps.label) LIKE ? ESCAPE '\\' OR lower(ps.url) LIKE ? ESCAPE '\\')) OR
EXISTS (SELECT 1 FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id WHERE pt.piece_id = p.id AND lower(t.name) LIKE ? ESCAPE '\\')
)`);
parameters.push(...Array(5).fill(`%${escapedQuery}%`));
}
if (filters.formatId) {
conditions.push('p.format_id = ?');
parameters.push(filters.formatId);
}
if (filters.topicId) {
conditions.push(
'EXISTS (SELECT 1 FROM piece_topics pt WHERE pt.piece_id = p.id AND pt.topic_id = ?)'
);
parameters.push(filters.topicId);
}
if (filters.categoryId) {
conditions.push(
'EXISTS (SELECT 1 FROM piece_category_suggestions pc WHERE pc.piece_id = p.id AND pc.category_id = ?)'
);
parameters.push(filters.categoryId);
}
const database = getDatabase();
const rows = database
.prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
WHERE ${conditions.join(' AND ')}
ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
p.updated_at_utc DESC, p.title`
)
.all(...parameters) as PieceListRow[];
const categoryStatement = database.prepare(
`SELECT c.id, c.name, c.color FROM piece_category_suggestions pc
JOIN section_categories c ON c.id = pc.category_id
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
);
const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name`
);
return rows.map((row) => ({
id: row.id,
title: row.title,
summary: row.summary,
status: row.status,
revision: row.revision,
durationSeconds: row.duration_seconds,
formatId: row.format_id,
format: row.format,
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count,
mediaId: row.media_id
}));
}
type PieceRow = {
id: string;
title: string;
summary: string;
format_id: string | null;
status: PieceStatus;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
revision: number;
created_at_utc: string;
updated_at_utc: string;
};
export function getPiece(id: string): PieceEditor | null {
const database = getDatabase();
const row = database
.prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc
FROM pieces WHERE id = ?`
)
.get(id) as PieceRow | undefined;
if (!row) return null;
return {
id: row.id,
title: row.title,
summary: row.summary,
formatId: row.format_id,
status: row.status,
durationSeconds: row.duration_seconds,
intro: row.intro,
researchText: row.research_text,
moderationNotes: row.moderation_notes,
transitionText: row.transition_text,
presentationText: row.presentation_text,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
topicIds: (
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
topic_id: string;
}[]
).map((entry) => entry.topic_id),
categoryIds: (
database
.prepare('SELECT category_id FROM piece_category_suggestions WHERE piece_id = ?')
.all(id) as { category_id: string }[]
).map((entry) => entry.category_id),
sources: (
database
.prepare(
'SELECT label, url, timecode_seconds FROM piece_sources WHERE piece_id = ? ORDER BY position'
)
.all(id) as { label: string; url: string; timecode_seconds: number | null }[]
).map((source) => ({
label: source.label,
url: source.url,
timecodeSeconds: source.timecode_seconds
})),
media: (
database
.prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position`
)
.all(id) as {
id: string;
original_name: string;
mime_type: string;
byte_size: number;
alt_text: string;
position: number;
}[]
).map((media) => ({
id: media.id,
originalName: media.original_name,
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text,
position: media.position
}))
};
}
function replaceRelations(pieceId: string, input: PieceInput) {
const database = getDatabase();
database.prepare('DELETE FROM piece_topics WHERE piece_id = ?').run(pieceId);
const addTopic = database.prepare('INSERT INTO piece_topics (piece_id, topic_id) VALUES (?, ?)');
for (const id of input.topicIds) addTopic.run(pieceId, id);
database.prepare('DELETE FROM piece_category_suggestions WHERE piece_id = ?').run(pieceId);
const addCategory = database.prepare(
'INSERT INTO piece_category_suggestions (piece_id, category_id) VALUES (?, ?)'
);
for (const id of input.categoryIds) addCategory.run(pieceId, id);
database.prepare('DELETE FROM piece_sources WHERE piece_id = ?').run(pieceId);
const addSource = database.prepare(
`INSERT INTO piece_sources (id, piece_id, label, url, timecode_seconds, position)
VALUES (?, ?, ?, ?, ?, ?)`
);
input.sources.forEach((source, position) =>
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position)
);
}
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1)
throw new Error('INVALID_REVISION');
const input = validatePieceInput(rawInput);
const database = getDatabase();
const updatedAtUtc = new Date().toISOString();
const save = database.transaction(() => {
const result = database
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(
input.title,
input.summary,
input.formatId,
input.status,
input.durationSeconds,
input.intro,
input.researchText,
input.moderationNotes,
input.transitionText,
input.presentationText,
updatedAtUtc,
id,
expectedRevision
);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
replaceRelations(id, input);
});
save();
return { revision: expectedRevision + 1, updatedAtUtc };
}
function templateDefaults(template: PieceTemplateId): Partial<PieceInput> & { title: string } {
if (template === 'talk') {
return {
title: 'Unbenanntes Gespräch',
formatId: 'format-talk',
categoryIds: ['cat-jc'],
moderationNotes: '## Leitfragen\n\n- '
};
}
if (template === 'find') {
return {
title: 'Neues Fundstück',
formatId: 'format-find',
categoryIds: ['cat-reaction'],
presentationText: '## Kernaussage\n\n'
};
}
if (template === 'interaction') {
return {
title: 'Neue Interaktion',
formatId: 'format-quiz',
categoryIds: ['cat-game'],
moderationNotes: '## Ablauf\n\n1. '
};
}
return { title: 'Neues Piece' };
}
export function createPiece(template: PieceTemplateId = 'blank'): string {
if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE');
const defaults = templateDefaults(template);
const id = randomUUID();
const now = new Date().toISOString();
const input = validatePieceInput({
title: defaults.title,
summary: '',
formatId: defaults.formatId ?? null,
status: 'preparing',
durationSeconds: 0,
intro: '',
researchText: '',
moderationNotes: defaults.moderationNotes ?? '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
sources: []
});
const database = getDatabase();
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
input.title,
input.summary,
input.formatId,
input.status,
input.durationSeconds,
input.intro,
input.researchText,
input.moderationNotes,
input.transitionText,
input.presentationText,
now,
now
);
replaceRelations(id, input);
})();
return id;
}
export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title,
status: 'inbox',
sources: url ? [{ label: 'Schnellaufnahme', url, timecodeSeconds: null }] : []
});
return id;
}
export function setPieceArchived(id: string, expectedRevision: number, archived: boolean) {
const database = getDatabase();
const now = new Date().toISOString();
const result = archived
? database
.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(now, id, expectedRevision)
: database
.prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'`
)
.run(now, id, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
}
export async function duplicatePiece(sourceId: string): Promise<string> {
const source = getPiece(sourceId);
if (!source) throw new Error('NOT_FOUND');
const id = randomUUID();
const now = new Date().toISOString();
const uploadDirectory = getServerPaths().uploads;
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase();
const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position')
.all(sourceId) as {
id: string;
storage_name: string;
original_name: string;
mime_type: string;
byte_size: number;
alt_text: string;
position: number;
}[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = [];
try {
for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!;
const storageName = `${randomUUID()}.${extension}`;
await copyFile(
path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName)
);
copied.push({ source: media, storageName });
}
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
`Kopie von ${source.title}`.slice(0, 200),
source.summary,
source.formatId,
source.durationSeconds,
source.intro,
source.researchText,
source.moderationNotes,
source.transitionText,
source.presentationText,
now,
now
);
replaceRelations(id, { ...source, status: 'preparing' });
const addMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const media of copied) {
addMedia.run(
randomUUID(),
id,
media.storageName,
media.source.original_name,
media.source.mime_type,
media.source.byte_size,
media.source.alt_text,
media.source.position,
now
);
}
})();
return id;
} catch (error) {
await Promise.all(
copied.map((media) => unlink(path.join(uploadDirectory, media.storageName)).catch(() => {}))
);
throw error;
}
}
export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,
file: File,
altTextValue: string
) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer());
const detected = await fileTypeFromBuffer(buffer);
const extension = detected ? ALLOWED_IMAGES.get(detected.mime) : undefined;
if (!detected || !extension) throw new Error('INVALID_IMAGE_TYPE');
const altText = cleanText(altTextValue, 300, 'alt');
const storageName = `${randomUUID()}.${extension}`;
const uploadDirectory = getServerPaths().uploads;
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase();
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?')
.get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
database
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
pieceId,
storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime,
buffer.byteLength,
altText,
count.count,
new Date().toISOString()
);
})();
} catch (error) {
await unlink(filePath).catch(() => {});
throw error;
}
}
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase();
const media = database
.prepare('SELECT storage_name FROM piece_media WHERE id = ? AND piece_id = ?')
.get(mediaId, pieceId) as { storage_name: string } | undefined;
if (!media) throw new Error('NOT_FOUND');
const originalPath = path.join(getServerPaths().uploads, media.storage_name);
const quarantinePath = `${originalPath}.deleting-${randomUUID()}`;
await rename(originalPath, quarantinePath);
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId);
})();
} catch (error) {
await rename(quarantinePath, originalPath).catch((restoreError) => {
console.error(
'Bilddatei konnte nach fehlgeschlagener Löschung nicht wiederhergestellt werden.',
restoreError
);
});
throw error;
}
try {
await unlink(quarantinePath);
} catch (error) {
console.error(
'Gelöschte Bilddatei konnte nicht aus dem Quarantänepfad entfernt werden.',
error
);
throw new Error('IMAGE_CLEANUP_FAILED', { cause: error });
}
}
export async function getMediaFile(id: string) {
const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?')
.get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null;
return {
data: await readFile(path.join(getServerPaths().uploads, row.storage_name)),
mimeType: row.mime_type,
byteSize: row.byte_size,
altText: row.alt_text
};
}
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase();
const duplicate = database
.prepare(
`SELECT 1 FROM ${kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics'}
WHERE lower(name) = lower(?)`
)
.get(name);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID();
if (kind === 'category') {
const color = String(colorValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
.get() as {
next: number;
};
database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next);
} else {
const table = kind === 'format' ? 'formats' : 'topics';
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
}
}
export function deleteTaxonomy(kind: TaxonomyKind, id: string) {
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
if (
['format-talk', 'format-find', 'format-quiz', 'cat-jc', 'cat-reaction', 'cat-game'].includes(id)
) {
throw new Error('TAXONOMY_IN_USE');
}
const database = getDatabase();
const usage =
kind === 'format'
? (
database.prepare('SELECT count(*) AS count FROM pieces WHERE format_id = ?').get(id) as {
count: number;
}
).count
: kind === 'topic'
? (
database
.prepare('SELECT count(*) AS count FROM piece_topics WHERE topic_id = ?')
.get(id) as {
count: number;
}
).count
: (
database
.prepare(
`SELECT
(SELECT count(*) FROM piece_category_suggestions WHERE category_id = ?) +
(SELECT count(*) FROM show_sections WHERE category_id = ?) AS count`
)
.get(id, id) as { count: number }
).count;
if (usage > 0) throw new Error('TAXONOMY_IN_USE');
const result = database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
+19 -3
View File
@@ -47,12 +47,28 @@ describe('database setup', () => {
(SELECT count(*) FROM schema_migrations) AS migrations, (SELECT count(*) FROM schema_migrations) AS migrations,
(SELECT count(*) FROM pieces) AS pieces, (SELECT count(*) FROM pieces) AS pieces,
(SELECT count(*) FROM shows) AS shows, (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(); database.close();
expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 }); expect(counts).toEqual({
migrations: 4,
pieces: 6,
shows: 2,
usages: 5,
templates: 2,
reserve_banks: 2
});
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite')); expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
}); });
+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' }
})
}
});
}
+50 -95
View File
@@ -1,4 +1,5 @@
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { getShow as getShowById, listShows } from './shows.js';
export type PreviewPiece = { export type PreviewPiece = {
id: string; 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 (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status != 'archived' WHERE p.status IN ('preparing', 'ready')
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title` ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
) )
.all() as PieceRow[]; .all() as PieceRow[];
@@ -104,109 +105,63 @@ export function getPieces(): PreviewPiece[] {
})); }));
} }
type ShowRow = { export function getShow(showId?: string): PreviewShow {
id: string; const latest = showId ? null : listShows().find((show) => show.startsAtUtc !== null);
title: string; const show = getShowById(showId ?? latest?.id ?? '');
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;
if (!show) throw new Error('No preview show found. Run npm run db:seed.'); if (!show) throw new Error('No preview show found. Run npm run db:seed.');
const sections: PreviewSection[] = show.sections.map((section) => ({
const sectionRows = database id: section.id,
.prepare( name: section.name,
`SELECT s.id, s.name, c.name AS category, c.color AS category_color, category: section.category ?? 'Offen',
s.explicit_duration_seconds categoryColor: section.categoryColor ?? '#8b8f98',
FROM show_sections s durationSeconds: section.durationSeconds,
LEFT JOIN section_categories c ON c.id = s.category_id usages: section.usages.map((usage) => ({
WHERE s.show_id = ? ORDER BY s.position` id: usage.id,
) title: usage.title,
.all(show.id) as SectionRow[]; durationSeconds: usage.durationSeconds,
const usageRows = database status: usage.status,
.prepare( intro: usage.intro,
`SELECT id, show_section_id, title_snapshot, duration_seconds, status, moderation: usage.moderation,
intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve transition: usage.transition,
FROM piece_usages WHERE show_section_id IN ( isReserve: false
SELECT id FROM show_sections WHERE show_id = ? }))
) ORDER BY position` }));
) const reserveHost = sections[Math.min(1, sections.length - 1)];
.all(show.id) as UsageRow[]; if (reserveHost) {
reserveHost.usages.push(
const sections = sectionRows.map((section) => { ...show.reserve.map((usage) => ({
const usages = usageRows
.filter((usage) => usage.show_section_id === section.id)
.map((usage) => ({
id: usage.id, id: usage.id,
title: usage.title_snapshot, title: usage.title,
durationSeconds: usage.duration_seconds, durationSeconds: usage.durationSeconds,
status: usage.status, status: usage.status,
intro: usage.intro_snapshot, intro: usage.intro,
moderation: usage.moderation_snapshot, moderation: usage.moderation,
transition: usage.transition_snapshot, transition: usage.transition,
isReserve: usage.is_reserve === 1 isReserve: true
})); }))
const plannedUsages = usages.filter((usage) => !usage.isReserve); );
return { }
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
};
});
return { return {
id: show.id, id: show.id,
title: show.title, title: show.title,
startsAtUtc: show.starts_at_utc, startsAtUtc: show.startsAtUtc ?? '',
startsAtBerlin: new Intl.DateTimeFormat('de-DE', { startsAtBerlin: show.startsAtUtc
timeZone: 'Europe/Berlin', ? new Intl.DateTimeFormat('de-DE', {
weekday: 'short', timeZone: 'Europe/Berlin',
day: '2-digit', weekday: 'short',
month: '2-digit', day: '2-digit',
year: 'numeric', month: '2-digit',
hour: '2-digit', year: 'numeric',
minute: '2-digit' hour: '2-digit',
}).format(new Date(show.starts_at_utc)), minute: '2-digit'
twitchTitle: show.twitch_title, }).format(new Date(show.startsAtUtc))
announcement: show.announcement_text, : 'Termin offen',
twitchTitle: show.twitchTitle,
announcement: show.announcementText,
status: show.status, status: show.status,
sections, sections,
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) totalDurationSeconds: show.totalDurationSeconds
}; };
} }
+272
View File
@@ -0,0 +1,272 @@
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,
getShow,
listShowTemplates,
movePieceUsage,
moveShowSection,
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,
intro: edited.intro,
research: edited.research,
moderation: edited.moderation,
transition: edited.transition,
presentation: edited.presentation,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds,
status: edited.status
};
}
describe('show builder data layer', () => {
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('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: [] }]
});
});
});
+796
View File
@@ -0,0 +1,796 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getDatabase } from './database.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
export type ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type ShowListItem = {
id: string;
title: string;
startsAtUtc: string | null;
status: ShowStatus;
revision: number;
totalDurationSeconds: number;
sectionCount: number;
};
export type ShowTemplateSection = {
id: string;
name: string;
categoryId: string | null;
position: number;
explicitDurationSeconds: number | null;
};
export type ShowTemplate = {
id: string;
name: string;
description: string;
sections: ShowTemplateSection[];
};
export type PieceUsage = {
id: string;
originalPieceId: string | null;
position: number;
status: UsageStatus;
durationSeconds: number;
title: string;
intro: string;
research: string;
moderation: string;
transition: string;
presentation: string;
ownNotes: string;
};
export type ShowSection = {
id: string;
name: string;
categoryId: string | null;
category: string | null;
categoryColor: string | null;
position: number;
explicitDurationSeconds: number | null;
durationSeconds: number;
usages: PieceUsage[];
};
export type Show = {
id: string;
title: string;
startsAtUtc: string | null;
twitchTitle: string;
announcementText: string;
status: ShowStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
sections: ShowSection[];
reserve: PieceUsage[];
totalDurationSeconds: number;
};
export type CreateShowInput = {
title: string | null;
startsAtUtc: string | null;
templateId: string | null;
};
export type ShowMetadataInput = {
title: string;
startsAtUtc: string | null;
twitchTitle: string;
announcementText: string;
status: EditableShowStatus;
};
export type ShowSectionInput = {
name: string;
categoryId: string | null;
explicitDurationSeconds: number | null;
position?: number;
};
export type PieceUsageInput = {
pieceId: string;
sectionId: string | null;
position?: number;
};
export type PieceUsageUpdate = {
title: string;
intro: string;
research: string;
moderation: string;
transition: string;
presentation: string;
ownNotes: string;
durationSeconds: number;
status: UsageStatus;
};
export type MovePieceUsageInput = {
sectionId: string | null;
position: number;
};
export type MutationResult = { revision: number; updatedAtUtc: string };
export type CreatedMutationResult = MutationResult & { id: string };
type ShowRow = {
id: string;
title: string;
starts_at_utc: string | null;
twitch_title: string;
announcement_text: string;
status: ShowStatus;
revision: number;
created_at_utc: string;
updated_at_utc: string;
};
type SectionRow = {
id: string;
name: string;
category_id: string | null;
category: string | null;
category_color: string | null;
position: number;
explicit_duration_seconds: number | null;
is_reserve_bank: number;
};
type UsageRow = {
id: string;
show_section_id: string;
original_piece_id: string | null;
position: number;
status: UsageStatus;
duration_seconds: number;
title_snapshot: string;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
presentation_snapshot: string;
own_notes: string;
};
function cleanText(value: unknown, maximum: number, field: string, required = false): string {
const text = String(value ?? '').trim();
if (text.length > maximum || (required && !text)) {
throw new Error(`INVALID_${field.toUpperCase()}`);
}
return text;
}
function validDuration(value: number | null): number | null {
if (value === null) return null;
if (!Number.isInteger(value) || value < 0 || value > 86_400) {
throw new Error('INVALID_DURATION');
}
return value;
}
function validPosition(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum);
}
function validTimestamp(value: string | null): string | null {
if (value === null) return null;
if (!value.endsWith('Z')) throw new Error('INVALID_TIMESTAMP');
const date = new Date(value);
if (Number.isNaN(date.valueOf())) throw new Error('INVALID_TIMESTAMP');
return date.toISOString();
}
function requireRevision(expectedRevision: number): void {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
}
function requireCategory(database: Database.Database, categoryId: string | null): void {
if (!categoryId) return;
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(categoryId)) {
throw new Error('INVALID_CATEGORY');
}
}
function bumpShow(
database: Database.Database,
showId: string,
expectedRevision: number
): MutationResult {
requireRevision(expectedRevision);
const updatedAtUtc = new Date().toISOString();
const result = database
.prepare(
`UPDATE shows SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'completed'`
)
.run(updatedAtUtc, showId, expectedRevision);
if (result.changes !== 1) {
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as
{ status: ShowStatus } | undefined;
if (!show) throw new Error('NOT_FOUND');
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
throw new Error('CONFLICT');
}
return { revision: expectedRevision + 1, updatedAtUtc };
}
function reserveSectionId(database: Database.Database, showId: string): string {
const row = database
.prepare('SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1')
.get(showId) as { id: string } | undefined;
if (!row) throw new Error('INVALID_RESERVE_BANK');
return row.id;
}
function sectionBelongsToShow(
database: Database.Database,
showId: string,
sectionId: string,
allowReserve = false
): void {
const row = database
.prepare('SELECT is_reserve_bank FROM show_sections WHERE id = ? AND show_id = ?')
.get(sectionId, showId) as { is_reserve_bank: number } | undefined;
if (!row || (!allowReserve && row.is_reserve_bank === 1)) throw new Error('NOT_FOUND');
}
function usageSection(
database: Database.Database,
showId: string,
usageId: string
): { sectionId: string } {
const row = database
.prepare(
`SELECT u.show_section_id AS sectionId 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 { sectionId: string } | undefined;
if (!row) throw new Error('NOT_FOUND');
return row;
}
function normalizePositions(
database: Database.Database,
table: 'show_sections' | 'piece_usages',
ids: string[]
): void {
if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', ');
const maximum = database
.prepare(
`SELECT COALESCE(max(position), -1) AS maximum FROM ${table} WHERE id IN (${placeholders})`
)
.get(...ids) as { maximum: number };
const offset = maximum.maximum + ids.length + 1;
database
.prepare(`UPDATE ${table} SET position = position + ? WHERE id IN (${placeholders})`)
.run(offset, ...ids);
const update = database.prepare(`UPDATE ${table} SET position = ? WHERE id = ?`);
ids.forEach((id, position) => update.run(position, id));
}
function usageFromRow(row: UsageRow): PieceUsage {
return {
id: row.id,
originalPieceId: row.original_piece_id,
position: row.position,
status: row.status,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
intro: row.intro_snapshot,
research: row.research_snapshot,
moderation: row.moderation_snapshot,
transition: row.transition_snapshot,
presentation: row.presentation_snapshot,
ownNotes: row.own_notes
};
}
export function listShows(): ShowListItem[] {
return (
getDatabase()
.prepare(
`SELECT sh.id, sh.title, sh.starts_at_utc, sh.status, sh.revision,
count(DISTINCT CASE WHEN s.is_reserve_bank = 0 THEN s.id END) AS section_count,
COALESCE(sum(CASE WHEN s.is_reserve_bank = 0 THEN
COALESCE(s.explicit_duration_seconds,
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
ELSE 0 END), 0) AS total_duration_seconds
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
GROUP BY sh.id
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
)
.all() as {
id: string;
title: string;
starts_at_utc: string | null;
status: ShowStatus;
revision: number;
section_count: number;
total_duration_seconds: number;
}[]
).map((row) => ({
id: row.id,
title: row.title,
startsAtUtc: row.starts_at_utc,
status: row.status,
revision: row.revision,
totalDurationSeconds: row.total_duration_seconds,
sectionCount: row.section_count
}));
}
export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase();
const templates = database
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
.all() as Omit<ShowTemplate, 'sections'>[];
const sectionStatement = database.prepare(
`SELECT id, name, category_id, position, explicit_duration_seconds
FROM show_template_sections WHERE template_id = ? ORDER BY position`
);
return templates.map((template) => ({
...template,
sections: (
sectionStatement.all(template.id) as {
id: string;
name: string;
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
}[]
).map((section) => ({
id: section.id,
name: section.name,
categoryId: section.category_id,
position: section.position,
explicitDurationSeconds: section.explicit_duration_seconds
}))
}));
}
export function getShow(id: string): Show | null {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc FROM shows WHERE id = ?`
)
.get(id) as ShowRow | undefined;
if (!show) return null;
const sectionRows = database
.prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.position, s.explicit_duration_seconds, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
.all(id) as SectionRow[];
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? ORDER BY s.position, u.position`
)
.all(id) as UsageRow[];
const usagesBySection = new Map<string, PieceUsage[]>();
for (const row of usageRows) {
const usages = usagesBySection.get(row.show_section_id) ?? [];
usages.push(usageFromRow(row));
usagesBySection.set(row.show_section_id, usages);
}
const sections = sectionRows
.filter((row) => row.is_reserve_bank === 0)
.map((row) => {
const usages = usagesBySection.get(row.id) ?? [];
return {
id: row.id,
name: row.name,
categoryId: row.category_id,
category: row.category,
categoryColor: row.category_color,
position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds,
durationSeconds:
row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
const reserveRow = sectionRows.find((row) => row.is_reserve_bank === 1);
return {
id: show.id,
title: show.title,
startsAtUtc: show.starts_at_utc,
twitchTitle: show.twitch_title,
announcementText: show.announcement_text,
status: show.status,
revision: show.revision,
createdAtUtc: show.created_at_utc,
updatedAtUtc: show.updated_at_utc,
sections,
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
};
}
export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase();
const id = randomUUID();
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString();
const create = database.transaction(() => {
if (
input.templateId &&
!database.prepare('SELECT 1 FROM show_templates WHERE id = ?').get(input.templateId)
) {
throw new Error('INVALID_TEMPLATE');
}
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
status, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
)
.run(id, title, startsAtUtc, now, now);
const templateSections = input.templateId
? (database
.prepare(
`SELECT name, category_id, position, explicit_duration_seconds
FROM show_template_sections WHERE template_id = ? ORDER BY position`
)
.all(input.templateId) as {
name: string;
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
}[])
: [];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)`
);
for (const section of templateSections) {
insertSection.run(
randomUUID(),
id,
section.name,
section.category_id,
section.position,
section.explicit_duration_seconds,
0
);
}
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
});
create();
return { id, revision: 1, updatedAtUtc: now };
}
export function saveShowMetadata(
showId: string,
expectedRevision: number,
input: ShowMetadataInput
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const twitchTitle = cleanText(input.twitchTitle, 200, 'twitch_title');
const announcementText = cleanText(input.announcementText, 20_000, 'announcement');
if (!['draft', 'ready', 'live'].includes(input.status)) throw new Error('INVALID_STATUS');
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
database
.prepare(
`UPDATE shows SET title = ?, starts_at_utc = ?, twitch_title = ?,
announcement_text = ?, status = ? WHERE id = ?`
)
.run(title, startsAtUtc, twitchTitle, announcementText, input.status, showId);
return result;
})();
}
export function addShowSection(
showId: string,
expectedRevision: number,
input: ShowSectionInput
): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const visibleIds = (
database
.prepare(
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
)
.all(showId) as { id: string }[]
).map((row) => row.id);
const position = validPosition(input.position ?? visibleIds.length, visibleIds.length);
const id = randomUUID();
const allIds = (
database
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
.all(showId) as {
id: string;
}[]
).map((row) => row.id);
const temporaryPosition = allIds.length;
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)`
)
.run(id, showId, name, input.categoryId, temporaryPosition, duration);
visibleIds.splice(position, 0, id);
const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
return { ...result, id };
})();
}
export function updateShowSection(
showId: string,
sectionId: string,
expectedRevision: number,
input: Omit<ShowSectionInput, 'position'>
): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
database
.prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?
WHERE id = ?`
)
.run(name, input.categoryId, duration, sectionId);
return result;
})();
}
export function removeShowSection(
showId: string,
sectionId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
const ids = (
database
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
.all(showId) as {
id: string;
}[]
).map((row) => row.id);
normalizePositions(database, 'show_sections', ids);
return result;
})();
}
export function moveShowSection(
showId: string,
sectionId: string,
targetPosition: number,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
const ids = (
database
.prepare(
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
)
.all(showId) as { id: string }[]
).map((row) => row.id);
const current = ids.indexOf(sectionId);
ids.splice(current, 1);
ids.splice(validPosition(targetPosition, ids.length), 0, sectionId);
ids.push(reserveSectionId(database, showId));
normalizePositions(database, 'show_sections', ids);
return result;
})();
}
export function addPieceUsage(
showId: string,
expectedRevision: number,
input: PieceUsageInput
): CreatedMutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const sectionId = input.sectionId ?? reserveSectionId(database, showId);
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database
.prepare(
`SELECT title, duration_seconds, intro, research_text, moderation_notes,
transition_text, presentation_text, status FROM pieces WHERE id = ?`
)
.get(input.pieceId) as
| {
title: string;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
status: string;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(sectionId) as { id: string }[]
).map((row) => row.id);
const position = validPosition(input.position ?? ids.length, ids.length);
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
sectionId,
input.pieceId,
ids.length,
piece.duration_seconds,
piece.title,
piece.intro,
piece.research_text,
piece.moderation_notes,
piece.transition_text,
piece.presentation_text,
input.sectionId === null ? 1 : 0
);
ids.splice(position, 0, id);
normalizePositions(database, 'piece_usages', ids);
return { ...result, id };
})();
}
export function updatePieceUsage(
showId: string,
usageId: string,
expectedRevision: number,
input: PieceUsageUpdate
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const duration = validDuration(input.durationSeconds)!;
if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS');
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
moderation: cleanText(input.moderation, 50_000, 'moderation'),
transition: cleanText(input.transition, 50_000, 'transition'),
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
};
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?`
)
.run(
title,
values.intro,
values.research,
values.moderation,
values.transition,
values.presentation,
values.ownNotes,
duration,
input.status,
usageId
);
return result;
})();
}
export function removePieceUsage(
showId: string,
usageId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(source.sectionId) as { id: string }[]
).map((row) => row.id);
normalizePositions(database, 'piece_usages', ids);
return result;
})();
}
export function movePieceUsage(
showId: string,
usageId: string,
expectedRevision: number,
input: MovePieceUsageInput
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
const targetSectionId = input.sectionId ?? reserveSectionId(database, showId);
sectionBelongsToShow(database, showId, targetSectionId, input.sectionId === null);
const sourceIds = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(source.sectionId) as { id: string }[]
).map((row) => row.id);
if (source.sectionId === targetSectionId) {
sourceIds.splice(sourceIds.indexOf(usageId), 1);
sourceIds.splice(validPosition(input.position, sourceIds.length), 0, usageId);
normalizePositions(database, 'piece_usages', sourceIds);
} else {
const targetIds = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(targetSectionId) as { id: string }[]
).map((row) => row.id);
const remainingSourceIds = sourceIds.filter((id) => id !== usageId);
normalizePositions(database, 'piece_usages', sourceIds);
normalizePositions(database, 'piece_usages', targetIds);
const temporaryPosition = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS position FROM piece_usages WHERE show_section_id = ?'
)
.get(targetSectionId) as { position: number }
).position;
database
.prepare(
`UPDATE piece_usages SET show_section_id = ?, position = ?, is_reserve = ? WHERE id = ?`
)
.run(targetSectionId, temporaryPosition, input.sectionId === null ? 1 : 0, usageId);
targetIds.splice(validPosition(input.position, targetIds.length), 0, usageId);
normalizePositions(database, 'piece_usages', remainingSourceIds);
normalizePositions(database, 'piece_usages', targetIds);
}
return result;
})();
}
+3
View File
@@ -0,0 +1,3 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user });
+78 -44
View File
@@ -5,14 +5,17 @@
import NavIcon from '#lib/components/NavIcon.svelte'; import NavIcon from '#lib/components/NavIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children } = $props(); let { children, data } = $props();
const navigation = [ const primaryNavigation = [
{ href: '/eingang', label: 'Eingang', icon: 'inbox' }, { href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' }, { href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' }, { href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' }, { href: '/archiv', label: 'Archiv', icon: 'archive' }
{ href: '/verwaltung', label: 'Verwaltung', icon: 'settings' }
] as const; ] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) { function isActive(href: string) {
return ( return (
@@ -28,15 +31,66 @@
/> />
</svelte:head> </svelte:head>
<div class="app-shell"> {#if data.user}
<aside class="sidebar"> <div class="app-shell">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite"> <aside class="sidebar">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span> <a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span>Sendewerk</span> <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
</a> <span>Sendewerk</span>
</a>
<nav class="main-navigation" aria-label="Hauptnavigation"> <nav class="main-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)} {#each primaryNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#if data.user.role === 'barkeeper'}
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
class:active={isActive(administration.href)}
aria-current={isActive(administration.href) ? 'page' : undefined}
>
<NavIcon name={administration.icon} />
<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>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<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">↗</button>
</form>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)}
<a <a
href={resolve(item.href)} href={resolve(item.href)}
class:active={isActive(item.href)} class:active={isActive(item.href)}
@@ -47,36 +101,16 @@
</a> </a>
{/each} {/each}
</nav> </nav>
</div>
<div class="sidebar-footer"> {:else}
<ThemeToggle /> <div class="auth-shell">
<div class="stage-indicator"> <header class="auth-header">
<span></span> <a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
<div><strong>Etappe 1</strong><small>Lokaler Entwurf</small></div> <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
</div> <span>Sendewerk</span>
</div>
</aside>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</a> </a>
{/each} <ThemeToggle />
</nav> </header>
</div> <main class="auth-main">{@render children()}</main>
</div>
{/if}
+4 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js';
export function load() { export function load({ locals }) {
redirect(307, '/fundus'); if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login');
} }
+22
View File
@@ -0,0 +1,22 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['archived'] }) };
};
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.' });
}
}
} satisfies Actions;
+28 -13
View File
@@ -1,20 +1,35 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head> <svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<p class="eyebrow">Bewahrte Sendungen</p>
<h1>Archiv</h1> <h1>Archiv</h1>
<p class="page-intro"> <p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
Abgeschlossene Abläufe bleiben hier in ihrem damaligen Stand nachvollziehbar.
</p>
</header> </header>
<section class="quiet-panel planned-panel"> {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<div class="planned-icon" aria-hidden="true">□</div> {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<p class="eyebrow">Geplant für Etappe 6</p> {#if data.pieces.length}<div class="archive-list">
<h2>Historie braucht klare Regeln.</h2> {#each data.pieces as piece (piece.id)}<article>
<p> <div>
Stabile Snapshots, Verwendungshistorie und bewusste Archivierung werden gemeinsam <h2>{piece.title}</h2>
implementiert, statt jetzt nur eine leere Liste vorzutäuschen. <p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
</p> </div>
</section> <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">□</div>
<h2>Keine Pieces archiviert</h2>
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section>{/if}
</div> </div>
+30
View File
@@ -0,0 +1,30 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } 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') || ''));
redirect(303, `/fundus/${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.'
});
}
}
} satisfies Actions;
+52 -13
View File
@@ -1,21 +1,60 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head> <svelte:head><title>Eingang | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<p class="eyebrow">Sammelstelle</p>
<h1>Eingang</h1> <h1>Eingang</h1>
<p class="page-intro"> <p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
Neue Gedanken und spätere RSS-Fundstücke landen hier, bevor sie in den Fundus übernommen
werden.
</p>
</header> </header>
<section class="quiet-panel planned-panel"> <section class="quiet-panel capture-panel">
<div class="planned-icon" aria-hidden="true">+</div> <div>
<p class="eyebrow">Geplant für Etappe 3 und 7</p> <p class="eyebrow">Schnellaufnahme</p>
<h2>Noch ist der Tresen leer.</h2> <h2>Was soll nicht verloren gehen?</h2>
<p> </div>
Schnellaufnahme, Feed-Vorschau und bewusste Übernahme werden erst mit den dazugehörigen {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
Speicher- und Sicherheitsregeln umgesetzt. <form method="POST" action="?/capture" class="capture-form">
</p> <label
>Titel oder Gedanke<input
name="title"
required
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">Aufnehmen und öffnen</button>
</form>
</section> </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)}<a
class="inbox-item"
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 →</span></a
>{/each}
</section>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<h2>Eingang ist leer</h2>
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div> </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>
+80 -3
View File
@@ -1,5 +1,82 @@
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,
duplicatePiece,
getTaxonomy,
listPieces,
pieceTemplates,
setPieceArchived,
type PieceTemplateId
} from '#lib/server/content.js';
export function load() { export const load: PageServerLoad = ({ locals, url }) => {
return { pieces: getPieces() }; 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') ?? ''
};
return {
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
}),
taxonomy: getTaxonomy(),
templates: pieceTemplates,
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') as PieceTemplateId);
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
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) });
}
}
} satisfies Actions;
+116 -63
View File
@@ -1,19 +1,9 @@
<script lang="ts"> <script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte'; import { resolve } from '$app/paths';
import { formatClock } from '#lib/format.js'; 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();
const statusLabel = { const statusLabel = {
ready: 'Sendefertig', ready: 'Sendefertig',
preparing: 'In Vorbereitung', preparing: 'In Vorbereitung',
@@ -27,75 +17,138 @@
<div class="page-wrap"> <div class="page-wrap">
<header class="page-heading split-heading"> <header class="page-heading split-heading">
<div> <div>
<p class="eyebrow">Redaktioneller Fundus</p> <h1>Fundus</h1>
<h1>Gute Stücke, sofort griffbereit.</h1> <p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
<p class="page-intro">
Vorbereitete Ideen, Gespräche und Fundstücke für die nächste Sendung.
</p>
</div> </div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}> <div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span> <strong>{data.pieces.length}</strong><span>Inhalte</span>
</div> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<PreviewNotice area="Fundus" /> <details class="template-picker">
<summary>Neues Piece anlegen</summary>
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern"> <div class="template-grid">
<label class="search-field"> {#each data.templates as template (template.id)}
<span aria-hidden="true">⌕</span> <form method="POST" action="?/create">
<span class="sr-only">Fundus durchsuchen</span> <input type="hidden" name="template" value={template.id} />
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" /> <button type="submit"
</label> ><strong>{template.name}</strong><span>{template.description}</span></button
<div class="filter-tabs" aria-label="Status filtern"> >
<button class:active={filter === 'all'} type="button" onclick={() => (filter = 'all')} </form>
>Alle</button {/each}
>
<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> </div>
</section> </details>
{#if filteredPieces.length > 0} <form class="library-filters" method="GET" aria-label="Fundus durchsuchen und filtern">
<label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<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
>
<button class="secondary-button" type="submit">Anwenden</button>
<a class="text-link" href={resolve('/fundus')}>Zurücksetzen</a>
</form>
{#if data.pieces.length}
<div class="piece-grid"> <div class="piece-grid">
{#each filteredPieces as piece (piece.id)} {#each data.pieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}> <article
<div class="piece-card-topline"> class="piece-card"
<span class="category-label"><i></i>{piece.category}</span> style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
>
{#if piece.mediaId}<img
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"
>S</span
>{/if}
<div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
</div>
<div class="piece-classification">
<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.id)}<span
>{topic.name}</span
>{/each}
</div>
</div>
<div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label" <span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span >{statusLabel[piece.status]}</span
><span class="piece-facts"
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
></span
> >
</div> </div>
<h2>{piece.title}</h2> <div class="piece-actions">
<p>{piece.summary}</p> <a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
<div class="piece-tags"> <form method="POST" action="?/duplicate">
<span>{piece.format}</span> <input type="hidden" name="pieceId" value={piece.id} /><button
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each} 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>
</div> </div>
<footer>
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</footer>
</article> </article>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true">⌕</span>
<h2>Kein passendes Stück</h2> <h2>Kein passendes Piece</h2>
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p> <p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<button <a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
type="button"
onclick={() => {
query = '';
filter = 'all';
}}>Filter zurücksetzen</button
>
</div> </div>
{/if} {/if}
</div> </div>
+175
View File
@@ -0,0 +1,175 @@
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,
savePiece,
setPieceArchived,
uploadPieceImage,
type EditablePieceStatus,
type PieceInput,
type PieceSourceInput
} from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.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
});
}
return {
title: text(data, 'title'),
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'),
topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String),
sources
};
}
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.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(), 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 });
}
},
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'));
} 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');
}
} satisfies Actions;
+364
View File
@@ -0,0 +1,364 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
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 rendered = $state(untrack(() => ({ ...data.rendered })));
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let editorForm: 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 pendingHref: string | null = null;
let allowNavigation = false;
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() {
generation += 1;
saveState = 'dirty';
saveMessage = 'Ungespeicherte Änderungen';
clearTimeout(timer);
timer = setTimeout(submitSave, 900);
}
function submitSave() {
if (saving) return;
editorForm.requestSubmit(saveButton);
}
function addSource() {
sources.push({ label: '', url: '', timecodeSeconds: null });
scheduleSave();
}
function removeSource(index: number) {
sources.splice(index, 1);
scheduleSave();
}
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="page-wrap editor-page">
<header class="editor-header">
<div>
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
bind:this={editorForm}
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<section class="editor-section editor-basics">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h2>Worum geht es?</h2>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
>
<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
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
<section class="editor-section taxonomy-editor">
<div class="section-title">
<p class="eyebrow">Einordnung</p>
<h2>Themen und Abschnitte</h2>
</div>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
{#each data.taxonomy.topics as item (item.id)}<label
><input
type="checkbox"
name="topicIds"
value={item.id}
bind:group={draft.topicIds}
/>{item.name}</label
>{/each}
</div>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h2>Getrennte Arbeitsbereiche</h2>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
</div>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
</div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
</section>
<div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
>Jetzt speichern</button
>
</div>
</form>
<section class="editor-section media-editor">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} />
<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
>
</form>
</section>
<section class="editor-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="secondary-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
>
</form>
</section>
</div>
+3 -3
View File
@@ -1,7 +1,7 @@
import { getShow } from '#lib/server/previews.js'; import { getShow } from '#lib/server/previews.js';
export function load() { export function load({ url }) {
const show = getShow(); const show = getShow(url.searchParams.get('show') || undefined);
const usages = show.sections.flatMap((section) => const usages = show.sections.flatMap((section) =>
section.usages section.usages
.filter((usage) => !usage.isReserve) .filter((usage) => !usage.isReserve)
@@ -11,5 +11,5 @@ export function load() {
0, 0,
usages.findIndex((usage) => usage.status === 'current') usages.findIndex((usage) => usage.status === 'current')
); );
return { show, current: usages[currentIndex], next: usages[currentIndex + 1] ?? null }; return { show, current: usages[currentIndex] ?? null, next: usages[currentIndex + 1] ?? null };
} }
+29 -25
View File
@@ -29,37 +29,41 @@
aria-label="Schrift vergrößern">A+</button aria-label="Schrift vergrößern">A+</button
> >
</div> </div>
<a href={resolve('/sendungen')}>Planung schließen</a> <a href={resolve('/sendungen/[id]', { id: data.show.id })}>Planung schließen</a>
</div> </div>
</header> </header>
<PreviewNotice area="Live-Modus" /> <PreviewNotice area="Live-Modus" />
<div class="live-grid"> <div class="live-grid">
<section class="live-current" style={`--prompt-size: ${promptSize}rem`}> {#if data.current}<section class="live-current" style={`--prompt-size: ${promptSize}rem`}>
<div class="live-kicker"> <div class="live-kicker">
<span>Aktuell · {data.current.section}</span><strong <span>Aktuell · {data.current.section}</span><strong
>{formatClock(data.current.durationSeconds)}</strong >{formatClock(data.current.durationSeconds)}</strong
> >
</div> </div>
<h2>{data.current.title}</h2> <h2>{data.current.title}</h2>
<div class="prompter-copy">{data.current.intro}</div> <div class="prompter-copy">{data.current.intro}</div>
<div class="moderation-card"> <div class="moderation-card">
<span>Moderationsstichpunkte</span> <span>Moderationsstichpunkte</span>
<p>{data.current.moderation}</p> <p>{data.current.moderation}</p>
</div> </div>
<details class="research-details"> <details class="research-details">
<summary>Recherche öffnen</summary> <summary>Recherche öffnen</summary>
<p> <p>
Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim
Öffnen die Leseposition. Öffnen die Leseposition.
</p> </p>
</details> </details>
<div class="transition-card"> <div class="transition-card">
<span>Überleitung</span> <span>Überleitung</span>
<p>{data.current.transition}</p> <p>{data.current.transition}</p>
</div> </div>
</section> </section>{:else}<section class="live-current live-empty">
<p class="eyebrow">Noch kein Piece eingeplant</p>
<h2>Diese Sendung hat noch keinen moderierbaren Inhalt.</h2>
<p>In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.</p>
</section>{/if}
<aside class="live-rail"> <aside class="live-rail">
<div class="next-card"> <div class="next-card">
+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() { function berlinLocalToUtc(value: string): string | null {
return { pieces: getPieces(), show: getShow() }; 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;
+84 -121
View File
@@ -1,141 +1,104 @@
<script lang="ts"> <script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PageData } from './$types'; import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props(); let { data, form }: PageProps = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let plannedCount = $derived( function berlinDate(value: string | null) {
data.show.sections.reduce( if (!value) return 'Termin offen';
(total, section) => total + section.usages.filter((usage) => !usage.isReserve).length, return new Intl.DateTimeFormat('de-DE', {
0 timeZone: 'Europe/Berlin',
) dateStyle: 'medium',
); timeStyle: 'short'
}).format(new Date(value));
}
</script> </script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page"> <div class="page-wrap shows-page">
<header class="page-heading show-heading"> <header class="page-heading">
<div> <div>
<p class="eyebrow">Nächste Sendung · {data.show.startsAtBerlin} Uhr</p> <h1>Sendungen</h1>
<h1>{data.show.title}</h1> <p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</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>
</div> </div>
</header> </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"> <section class="show-create-panel">
<button <div>
class:active={mobilePane === 'fundus'} <p class="eyebrow">Neuen Stream planen</p>
type="button" <h2>Leer starten oder Ablauf übernehmen</h2>
onclick={() => (mobilePane = 'fundus')}>Fundus</button </div>
> <form method="POST" action="?/create" class="show-create-form">
<button class:active={mobilePane === 'plan'} type="button" onclick={() => (mobilePane = 'plan')} <label
>Ablauf</button >Titel<input
> name="title"
</div> required
maxlength="200"
value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung"
/></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label
>
<label
>Ausgangspunkt<select name="templateId" value={form?.templateId ?? ''}
><option value="">Leer starten</option
>{#each data.templates as template (template.id)}<option value={template.id}
>{template.name}</option
>{/each}</select
></label
>
<button class="primary-button" type="submit">Sendung anlegen</button>
</form>
<div class="template-notes">
{#each data.templates as template (template.id)}<p>
<strong>{template.name}:</strong>
{template.description}
</p>{/each}
</div>
</section>
<div class="builder-workspace"> <section class="show-library" aria-labelledby="show-library-title">
<section <header>
class="builder-pane source-pane" <h2 id="show-library-title">Geplante Sendungen</h2>
class:mobile-hidden={mobilePane !== 'fundus'} <span>{data.shows.length}</span>
aria-labelledby="source-title" </header>
> {#if data.shows.length}
<header class="pane-heading"> <div class="show-card-list">
<div> {#each data.shows as show (show.id)}
<p class="eyebrow">Quelle</p> <a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<label class="compact-search">
<span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span>
<input
type="search"
placeholder="Fundus durchsuchen"
disabled
title="Die vollständige Suche folgt in Etappe 3"
/>
</label>
<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> <div>
<span class="category-label"><i></i>{piece.category}</span><span <span class={`show-state ${show.status}`}
>{formatClock(piece.durationSeconds)}</span >{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> </div>
<h3>{piece.title}</h3> <div class="show-card-facts">
<p>{piece.format} · {piece.topics.join(', ')}</p> <span><strong>{show.sectionCount}</strong> Abschnitte</span><span
</article> ><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
><b aria-hidden="true">→</b>
</div>
</a>
{/each} {/each}
</div> </div>
<div class="reserve-box"> {:else}
<div> <div class="empty-state">
<span aria-hidden="true">◇</span> <span aria-hidden="true">+</span>
<div><strong>Reservebank</strong><small>1 Stück vorbereitet</small></div> <h2>Noch keine Sendung</h2>
</div> <p>Oben kann der erste Stream geplant werden.</p>
<p>„Pixelart, die nicht nostalgisch sein will“ wartet als Ersatz.</p>
</div> </div>
</section> {/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)}
<article class="run-section" style={`--category: ${section.categoryColor}`}>
<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>
</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">
<span class="drag-hint" aria-hidden="true">⠿</span>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Eigene Sendungskopie</small
>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
>{usage.status === 'current' ? 'Aktuell' : 'Offen'}</span
>
</div>
{/each}
</div>
{:else}
<div class="section-placeholder">
Eigenständiger Spieleblock · keine Piece-Kopie nötig
</div>
{/if}
</article>
{/each}
</div>
</section>
</div>
</div> </div>
+222
View File
@@ -0,0 +1,222 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getTaxonomy, listPieces } from '#lib/server/content.js';
import {
addPieceUsage,
addShowSection,
getShow,
movePieceUsage,
moveShowSection,
removePieceUsage,
removeShowSection,
saveShowMetadata,
updatePieceUsage,
updateShowSection,
type EditableShowStatus,
type UsageStatus
} 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 === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
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');
return {
show,
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: listPieces({
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
}),
taxonomy: getTaxonomy(),
filters: {
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
}
};
};
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
})
);
},
updateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('updateUsage', () =>
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
title: text(data, 'title'),
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'),
status: text(data, 'status') as UsageStatus
})
);
},
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;
+872
View File
@@ -0,0 +1,872 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import { flip } from 'svelte/animate';
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 draggedSectionId = $state('');
let draggedUsageId = $state('');
let draggedPieceId = $state('');
let dropSectionId = $state('');
let dropPosition = $state(0);
let copyMessage = $state('');
let plannedCount = $derived(
data.show.sections.reduce((total, section) => total + section.usages.length, 0)
);
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 durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
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());
}
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 }) => {
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')}>← 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-Vorschau <span>↗</span></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: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.length}</span>
</header>
<form 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-source-list">
{#each data.pieces 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>{piece.title}</h3>
<p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
</p>
<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 !data.pieces.length}<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Nichts gefunden</h2>
<p>Filter ändern 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`}>↑</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`}>↓</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)} · sendungsspezifische Kopie</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`}>↑</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`}>↓</button
>
</form>
</div>
<details class="usage-editor">
<summary>Bearbeiten und verschieben</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<label
>Titel<input
name="title"
required
maxlength="200"
value={usage.title}
/></label
><label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="2"
>{usage.ownNotes}</textarea
></label
><label class="wide-field"
>Einleitung<textarea name="intro" rows="2">{usage.intro}</textarea
></label
><label class="wide-field"
>Recherche<textarea name="research" rows="2"
>{usage.research}</textarea
></label
><label class="wide-field"
>Moderation<textarea name="moderation" rows="2"
>{usage.moderation}</textarea
></label
><label class="wide-field"
>Überleitung<textarea name="transition" rows="2"
>{usage.transition}</textarea
></label
><label class="wide-field"
>Präsentation<textarea name="presentation" rows="2"
>{usage.presentation}</textarea
></label
>
<input type="hidden" name="status" value={usage.status} /><button
class="primary-button wide-field"
type="submit">Verwendung speichern</button
>
</form>
<div class="usage-command-row">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="position"
value="9999"
/><label
>Ziel<select name="sectionId"
><option value="">Reservebank</option
>{#each data.show.sections as target (target.id)}<option
value={target.id}
selected={target.id === section.id}>{target.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') &&
event.preventDefault()}
>
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><button
class="text-button danger-text"
type="submit">Entfernen</button
>
</form>
</div>
</details>
</div>
</div>
{/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">
<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 Reserve entfernen`}>Entfernen</button
>
</form>
</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>
</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>
+133 -3
View File
@@ -1,5 +1,135 @@
import { getTaxonomy } from '#lib/server/previews.js'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
createTaxonomy,
deleteTaxonomy,
getTaxonomy,
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import {
inviteUser,
isRole,
listOpenInvitations,
listUsers,
requireBarkeeper,
revokeInvitation,
setUserLocked,
setUserRole,
validEmail
} from '#lib/server/auth.js';
export function load() { export const load: PageServerLoad = ({ locals }) => {
return { taxonomy: getTaxonomy() }; requireBarkeeper(locals.user);
return {
taxonomy: getTaxonomy(),
users: listUsers(),
invitations: listOpenInvitations(),
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.';
return 'Die Aktion konnte nicht ausgeführt werden.';
} }
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') || ''));
return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
} 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) });
}
}
} satisfies Actions;
+174 -21
View File
@@ -1,50 +1,203 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageProps } from './$types';
let { data }: { data: PageData } = $props(); let { data, form }: PageProps = $props();
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
}
</script> </script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head> <svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<p class="eyebrow">System & Struktur</p>
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p class="page-intro"> <p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p>
Die bereits angelegte Taxonomie ist sichtbar; Bearbeitung und Benutzerverwaltung folgen mit
serverseitigen Rechten.
</p>
</header> </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">Taxonomie</h2>
<div class="settings-grid"> <div class="settings-grid">
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p> <p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2> <h2>Struktur im Ablauf</h2>
<div class="taxonomy-list"> <div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.categories as category (category.id)}<span {#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`}>×</button>
</form></span
>{/each} >{/each}
</div> </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>
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p> <p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2> <h2>Form des Stücks</h2>
<div class="taxonomy-list"> <div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.formats as format (format.id)}<span>{format.name}</span>{/each} {#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`}>×</button>
</form></span
>{/each}
</div> </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>
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Themen</p> <p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2> <h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list"> <div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each} {#each data.taxonomy.topics as topic (topic.id)}<span
>{topic.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" /><input
type="hidden"
name="taxonomyId"
value={topic.id}
/><button type="submit" aria-label={`${topic.name} entfernen`}>×</button>
</form></span
>{/each}
</div> </div>
</section> <form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<section class="quiet-panel planned-settings"> <input type="hidden" name="kind" value="topic" /><label
<p class="eyebrow">Etappe 2</p> ><span class="sr-only">Neues Thema</span><input
<h2>Konten & Rollen</h2> name="name"
<p> required
Barkeeper, Thekencrew und technische Zugänge werden erst mit vollständiger Authentifizierung maxlength="80"
verwaltbar. placeholder="Neues Thema"
</p> /></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section> </section>
</div> </div>
</div> </div>
+10 -1
View File
@@ -2,11 +2,20 @@
set -eu set -eu
mkdir -p /app/data/uploads mkdir -p /app/data/uploads /app/data/mail-outbox
chown -R cloudron:cloudron /app/data chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}" export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}" 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 HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-3000}" export PORT="${PORT:-3000}"
+567 -36
View File
@@ -1,77 +1,496 @@
import { expect, test } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
test('desktop fundus is usable, filterable and keeps its theme', async ({ page }, testInfo) => { const adminEmail = 'barkeeper@example.test';
const adminPassword = 'Nur-fuer-E2E-Admin-1234';
const crewEmail = 'crew@example.test';
const crewPassword = 'Nur-fuer-E2E-Crew-1234';
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
const viewports = [
{ width: 390, height: 844, theme: 'light' },
{ width: 390, height: 844, theme: 'dark' },
{ width: 768, height: 900, theme: 'light' },
{ width: 768, height: 900, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'light' }
] as const;
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
}
async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
await page.getByLabel('Passwort', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL(/\/fundus$/);
}
async function openSeededShow(page: Page) {
await page.goto('/sendungen');
await page.getByRole('link', { name: /Herbstsendung: Kleine Welten/ }).click();
await expect(page).toHaveURL(/\/sendungen\/show-october$/);
}
function latestMailLink(subject: string): string {
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
const messages = readdirSync(outbox)
.sort()
.map(
(file) =>
JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as {
subject: string;
text: string;
}
)
.filter((message) => message.subject === subject);
const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/);
if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`);
return match[0];
}
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('/');
if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) {
await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters');
await page.getByLabel('Name').fill('E2E Barkeeper');
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Barkeeper anlegen' }).click();
} else {
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
}
await expect(page).toHaveURL(/\/fundus$/);
const sessionCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'sendewerk_session'
);
expect(sessionCookie?.httpOnly).toBe(true);
expect(sessionCookie?.secure).toBe(false);
expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await page.close();
});
test.beforeEach(async ({ page }) => {
await login(page);
});
test('invitations, direct authorization, locking and password reset work end to end', async ({
page,
browser
}, testInfo) => {
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-konten-desktop.png'),
fullPage: true
});
const crossSite = await page.request.post('/verwaltung?/invite', {
form: { email: 'cross-site@example.test', role: 'barkeeper' },
headers: { origin: 'https://example.invalid' }
});
expect(crossSite.status()).toBe(403);
const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' });
await inviteForm.getByLabel('E-Mail').fill(crewEmail);
await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click();
await expect(
page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.')
).toBeVisible();
const crewContext = await browser.newContext();
const crewPage = await crewContext.newPage();
await crewPage.goto(latestMailLink('Einladung zu Sendewerk'));
await expect(crewPage).toHaveURL(/\/einladung$/);
await crewPage.getByLabel('Name').fill('E2E Crew');
await crewPage.getByLabel('Passwort').fill(crewPassword);
await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click();
await expect(crewPage).toHaveURL(/\/fundus$/);
await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0);
const forbidden = await crewPage.request.post('/verwaltung?/invite', {
form: { email: 'verboten@example.test', role: 'barkeeper' },
headers: { origin: 'http://127.0.0.1:4173' }
});
expect(forbidden.status()).toBe(403);
await page.reload();
const crewRow = page.locator('.account-row').filter({ hasText: crewEmail });
await crewRow.getByRole('button', { name: 'Sperren' }).click();
await crewPage.goto('/fundus');
await expect(crewPage).toHaveURL(/\/login$/);
await page.reload();
await page
.locator('.account-row')
.filter({ hasText: crewEmail })
.getByRole('button', { name: 'Entsperren' })
.click();
await crewPage.goto('/passwort-vergessen');
await crewPage.getByLabel('E-Mail').fill(crewEmail);
await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click();
await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible();
await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen'));
await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/);
await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234');
await crewPage.getByRole('button', { name: 'Passwort speichern' }).click();
await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234');
await crewContext.close();
const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const loginPage = await anonymousContext.newPage();
await loginPage.goto('/login');
await setTheme(loginPage, 'dark');
await loginPage.reload();
await expectNoHorizontalOverflow(loginPage);
await loginPage.screenshot({
path: testInfo.outputPath('login-mobile-dark.png'),
fullPage: true
});
await anonymousContext.close();
});
test('desktop fundus is a compact, filterable list with persistent themes', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus'); await page.goto('/fundus');
await expect( await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
page.getByRole('heading', { name: 'Gute Stücke, sofort griffbereit.' }) await expect(page.locator('.sidebar')).toBeVisible();
).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.getByText('Gestaltungsvorschau: Fundus')).toBeVisible(); await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(4);
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const statusBox = await page.getByLabel('Status').boundingBox();
expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart'); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await expect( await page.getByLabel('Status').selectOption('preparing');
page.getByRole('heading', { name: 'Pixelart, die nicht nostalgisch sein will' }) await page.getByRole('button', { name: 'Anwenden' }).click();
).toBeVisible(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page.getByRole('link', { name: 'Filter zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.getByRole('button', { name: 'Darstellung: System' }).click(); await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload(); await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
await expectNoHorizontalOverflow(page);
const dimensions = await page.evaluate(() => ({ await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop.png'), fullPage: true });
}); });
test('desktop builder shows fundus and run side by side', async ({ page }) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/sendungen'); await page.goto('/fundus');
await setTheme(page, 'dark');
await openSeededShow(page);
await expect(page.getByText('Gestaltungsvorschau: Sendungsbau')).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeHidden();
await expect( await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' }) page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible(); ).toBeVisible();
const boxes = await Promise.all([ const sourceBox = await page.locator('.source-pane').boundingBox();
page.locator('.source-pane').boundingBox(), const runBox = await page.locator('.run-pane').boundingBox();
page.locator('.run-pane').boundingBox() expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
]); expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
expect(boxes[0]?.x).toBeLessThan(boxes[1]?.x ?? 0); await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-desktop-dark.png'),
fullPage: true
});
}); });
test('mobile builder switches panes without horizontal scrolling', async ({ page }, testInfo) => { test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 960, height: 850 });
await page.goto('/sendungen'); await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden(); await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden(); await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
});
const dimensions = await page.evaluate(() => ({ test('mobile and tablet navigation do not obscure switchable content', async ({
scrollWidth: document.documentElement.scrollWidth, page
clientWidth: document.documentElement.clientWidth }, testInfo) => {
})); for (const width of [390, 768]) {
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); await page.setViewportSize({ width, height: 900 });
await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const verticalSpace = await page.evaluate(() => {
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
});
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
}
await page.setViewportSize({ width: 390, height: 844 });
await openSeededShow(page);
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
}); });
test('live preview exposes readable prompts and working text controls', async ({ page }) => { test('a show can be planned from a template with independent usages and reserve', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen');
await page.getByLabel('Titel').fill('E2E Planung Spiele und JC');
await page.getByLabel('Ausgangspunkt').selectOption('template-default');
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
const showUrl = page.url();
await expect(page.locator('.run-section')).toHaveCount(5);
const firstSection = page.locator('.run-section').first();
const sectionTransfer = await page.evaluateHandle(() => new DataTransfer());
await firstSection.locator('.section-drag-handle').dispatchEvent('dragstart', {
dataTransfer: sectionTransfer
});
await page.locator('.run-section').nth(1).dispatchEvent('dragover', {
dataTransfer: sectionTransfer
});
await expect(page.locator('[data-drop-preview="section"]')).toContainText('Start');
await expect(firstSection).toHaveClass(/is-dragging/);
await firstSection.locator('.section-drag-handle').dispatchEvent('dragend', {
dataTransfer: sectionTransfer
});
await sectionTransfer.dispose();
await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const source = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await expect(page.locator('.builder-source-card')).toHaveCount(1);
const reactionSection = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) });
await page.locator('body').evaluate((body) => (body.dataset.builderDocument = 'stable'));
const sourceDragHandle = source.locator('.source-drag-copy');
const emptyReactionTarget = reactionSection.locator('.section-placeholder');
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await sourceDragHandle.dispatchEvent('dragstart', { dataTransfer });
await emptyReactionTarget.dispatchEvent('dragover', { dataTransfer });
const insertionPreview = reactionSection.locator('[data-drop-preview="usage"]');
await expect(insertionPreview).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(source).toHaveClass(/is-dragging/);
await expect(source).toHaveCSS('opacity', '0.45');
await emptyReactionTarget.dispatchEvent('drop', { dataTransfer });
await sourceDragHandle.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(1);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage'));
let usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await expect(usages).toHaveCount(2);
await usages.nth(0).locator('summary').click();
await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt');
await usages.nth(0).getByLabel('Dauer in Minuten').fill('5');
await usages.nth(0).getByLabel('Eigene Notizen').fill('Kurbel zuerst zeigen');
await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages.nth(1).locator('summary').click();
await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick');
await usages.nth(1).getByLabel('Dauer in Minuten').fill('15');
await usages.nth(1).getByLabel('Eigene Notizen').fill('Andere Perspektive');
await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
const planTimeBeforeReserve = await page.locator('.show-summary').textContent();
await source.getByLabel(/Ziel für/).selectOption('');
await source.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(page.locator('.reserve-usage')).toHaveCount(1);
expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve);
const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' });
await moveUp.focus();
await page.keyboard.press('Enter');
await expect(
page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage strong')
.first()
).toHaveText('Konsole als Rückblick');
const reserveUsage = page.locator('.reserve-usage');
const reserveHandle = reserveUsage.locator('.usage-drag-copy');
const finalSectionTarget = page.locator('.run-section').last().locator('.section-placeholder');
const reserveTransfer = await page.evaluateHandle(() => new DataTransfer());
await reserveHandle.dispatchEvent('dragstart', { dataTransfer: reserveTransfer });
await finalSectionTarget.dispatchEvent('dragover', { dataTransfer: reserveTransfer });
await expect(page.locator('[data-drop-preview="usage"]')).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveHandle.dispatchEvent('dragend', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await page.reload();
await expect(page.locator('.editable-usage')).toHaveCount(3);
const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' });
await savedUsage.locator('summary').click();
await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click();
await expect(page.getByRole('heading', { name: 'Finales Spiel' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true });
await page.goto('/fundus?q=kleine+Konsole');
await expect(
page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.goto(showUrl);
});
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/fundus');
await setTheme(page, viewport.theme);
for (const route of routes) {
await page.goto(route);
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 768, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
fullPage: true
});
});
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.evaluate(() => {
const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card'));
const title = rows[0]?.querySelector('h2');
if (title) {
title.textContent =
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
}
for (let repetition = 0; repetition < 3; repetition += 1) {
for (const row of rows) list?.append(row.cloneNode(true));
}
});
await expect(page.locator('.piece-card')).toHaveCount(16);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
const mobileCard = await page.locator('.piece-card').first().boundingBox();
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
'Ein außergewöhnlich langer redaktioneller Titel'
);
await page.screenshot({
path: testInfo.outputPath('fundus-mobile-long-content.png'),
fullPage: true
});
});
test('inbox, archive and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/eingang');
await expect(page.locator('.capture-panel')).toBeVisible();
await expect(page.locator('.inbox-item')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
await page.goto('/archiv');
await expect(page.locator('.archive-list article')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/verwaltung');
const panels = page.locator('.taxonomy-panel');
await expect(panels).toHaveCount(3);
const boxes = await Promise.all([
panels.nth(0).boundingBox(),
panels.nth(1).boundingBox(),
panels.nth(2).boundingBox()
]);
expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y);
});
test('live preview retains its existing readable controls', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live'); await page.goto('/live');
await expect(page.getByText('Gestaltungsvorschau: Live-Modus')).toBeVisible(); await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible(); await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
const before = await page const before = await page
.locator('.live-current') .locator('.live-current')
@@ -83,4 +502,116 @@ test('live preview exposes readable prompts and working text controls', async ({
expect(after).not.toBe(before); expect(after).not.toBe(before);
await page.getByText('Recherche öffnen').click(); await page.getByText('Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await expectNoHorizontalOverflow(page);
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page,
browser
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus');
await page.getByText('Neues Piece anlegen').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url();
await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown');
await page.getByRole('link', { name: '← Fundus', exact: true }).click();
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check();
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await page
.getByLabel('Präsentationstext')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag');
await source.getByLabel('URL').fill('https://example.com/e2e-fundstueck');
await source.getByLabel('Zeitmarke in Sekunden').fill('42');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png',
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Alternativtext').fill('Ein Testpixel');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
const image = page.locator('.media-grid img');
await expect(image).toHaveCount(1);
await expect(image).toHaveAttribute('alt', 'Ein Testpixel');
const mediaUrl = await image.getAttribute('src');
const anonymous = await browser.newContext();
const anonymousResponse = await anonymous.request.get(new URL(mediaUrl!, page.url()).toString(), {
maxRedirects: 0
});
expect(anonymousResponse.status()).toBe(303);
await anonymous.close();
const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
await expect(concurrent.locator('.save-indicator')).toContainText('neuere Änderung', {
timeout: 10_000
});
await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
await concurrent.close();
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible();
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
await expect(archived).toBeVisible();
await archived.getByRole('button', { name: 'Wiederherstellen' }).click();
await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(page.getByLabel('Titel')).toHaveValue(
'Kopie von E2E Fundstück, extern aktualisiert'
);
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
fullPage: true
});
}); });
+4 -1
View File
@@ -9,7 +9,10 @@ export default defineConfig({
runes: ({ filename }) => runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true filename.split(/[/\\]/).includes('node_modules') ? undefined : true
}, },
adapter: adapter() adapter: adapter(),
paths: {
origin: process.env.SENDEWERK_APP_ORIGIN
}
}) })
], ],
test: { test: {