2 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
40 changed files with 6755 additions and 356 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads
SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox
BODY_SIZE_LIMIT=6M
# Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN=
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.2.0",
"version": "0.4.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+37 -6
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
Etappe 1 und 2: technisches Grundgerüst, persistente Beispieldaten,
Gestaltungsvorschauen sowie echte Anmeldung mit Barkeeper-/Thekencrew-Rollen.
Inhaltsbearbeitung und Live-Steuerung folgen in späteren Etappen.
Etappe 1 bis 4: technisches Grundgerüst, Anmeldung und Rollen, einen
persistenten Fundus sowie den vollständigen Sendungsbaukasten. Live-Steuerung
und Präsentationsausgabe folgen in späteren Etappen.
## Voraussetzungen
@@ -46,6 +46,7 @@ enthält bewusst kein Konto und kein Standardpasswort.
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
| `BODY_SIZE_LIMIT` | `6M` | Requestlimit für private Bild-Uploads bis 5 MB |
| `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen |
| `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit |
| `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` |
@@ -67,6 +68,36 @@ 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
| Befehl | Zweck |
@@ -142,6 +173,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` und `@node-rs/argon2` sind MIT-lizenziert; Nodemailer verwendet
MIT-0, TypeScript und Playwright stehen unter Apache-2.0. Die Lockdatei fixiert
den tatsächlich installierten Baum.
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind
MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen
unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
+54
View File
@@ -66,3 +66,57 @@
- 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
+73 -13
View File
@@ -31,17 +31,17 @@ spätere API dieselben Regeln verwenden.
- Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative
ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`.
## Geplantes Gesamtdatenmodell
## Gesamtdatenmodell
Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten
Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
Die ersten vier Etappen materialisieren Identität, Inhaltsverwaltung und
Sendungsplanung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 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 |
| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
| 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 sind umgesetzt; Original-ID bleibt Referenz |
| 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 |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
@@ -89,6 +89,66 @@ 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
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
@@ -101,14 +161,14 @@ Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht.
Etappe 1 zeigt Fundus, Sendungsbau und Live-Modus als sichtbare
`Gestaltungsvorschau`. Die Daten kommen bereits aus SQLite, mutierende
Fachabläufe werden jedoch nicht vorgetäuscht.
Fundus und Sendungsbau sind produktive Arbeitsansichten. Der Live-Modus bleibt
bis Etappe 5 eine sichtbare Gestaltungsvorschau, kann aber bereits gezielt die
gewählte Sendung und deren kopierte Texte lesen.
## Sicherheitsgrenzen ab den Folgeetappen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere
Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und
kontrollierte Redirects.
+67
View File
@@ -135,3 +135,70 @@ 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>
+45 -8
View File
@@ -2,9 +2,9 @@
## Aktueller Umfang
Etappe 1 und Etappe 2 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen und lokaler Betriebsweg sind dokumentiert. Etappe 3 wurde
nicht begonnen.
Etappe 1 bis Etappe 4 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung und lokaler
Betriebsweg sind dokumentiert. Etappe 5 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1
@@ -35,12 +35,11 @@ Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen.
## Nicht Bestandteil dieser Etappe
## Noch nicht umgesetzt
Echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Live-Steuerung,
Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
werden.
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
@@ -53,6 +52,44 @@ werden.
- 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
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
+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);
+294 -10
View File
@@ -1,16 +1,19 @@
{
"name": "sendewerk",
"version": "0.2.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.2.0",
"version": "0.4.0",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"nodemailer": "^10.1.7"
"file-type": "^22.2.0",
"marked": "^18.1.1",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -20,6 +23,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
@@ -37,6 +41,16 @@
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -951,6 +965,29 @@
"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": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1010,6 +1047,16 @@
"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": {
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
@@ -1475,11 +1522,16 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1527,6 +1579,85 @@
"dev": true,
"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": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1538,7 +1669,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1859,6 +1989,24 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -1956,6 +2104,48 @@
"dev": true,
"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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1999,6 +2189,15 @@
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -2047,6 +2246,15 @@
"dev": true,
"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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2365,6 +2573,18 @@
"@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": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2405,14 +2625,12 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2516,6 +2734,12 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2540,7 +2764,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2589,7 +2812,6 @@
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2853,6 +3075,23 @@
"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": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -2908,7 +3147,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2921,6 +3159,22 @@
"dev": true,
"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": {
"version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -3099,6 +3353,24 @@
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -3173,6 +3445,18 @@
"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": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.2.0",
"version": "0.4.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
@@ -26,7 +26,10 @@
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"nodemailer": "^10.1.7"
"file-type": "^22.2.0",
"marked": "^18.1.1",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -36,6 +39,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
+160 -9
View File
@@ -124,9 +124,49 @@ export function seed(databasePath = getPaths().database) {
'Pixel als bewusste Entscheidung.',
timestamp,
timestamp
],
[
'piece-inbox-link',
'Indie-Showcase nachhören',
'Ein Fundstück aus dem letzten Showcase, das noch eingeordnet werden muss.',
'format-find',
'inbox',
0,
'',
'',
'',
'',
timestamp,
timestamp
],
[
'piece-archive-demo',
'Controller mit eingebautem Bildschirm',
'Ein früheres Fundstück, das bewusst im Archiv liegt.',
'format-find',
'archived',
300,
'',
'',
'',
'',
timestamp,
timestamp
]
]
);
database
.prepare('UPDATE pieces SET research_text = ? WHERE id = ?')
.run(
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'piece-console'
);
database
.prepare('UPDATE pieces SET presentation_text = ? WHERE id = ?')
.run(
'## Weniger Leistung, mehr Idee\n\nDie Kurbel ist Teil des Spiels, nicht nur Dekoration.',
'piece-console'
);
database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%');
const addTopic = database.prepare(
@@ -137,7 +177,9 @@ export function seed(databasePath = getPaths().database) {
['piece-effects', 'topic-film'],
['piece-quiz', 'topic-gaming'],
['piece-pixels', 'topic-gaming'],
['piece-pixels', 'topic-internet']
['piece-pixels', 'topic-internet'],
['piece-inbox-link', 'topic-gaming'],
['piece-archive-demo', 'topic-gaming']
])
addTopic.run(...values);
@@ -149,7 +191,9 @@ export function seed(databasePath = getPaths().database) {
['piece-console', 'cat-jc'],
['piece-effects', 'cat-reaction'],
['piece-quiz', 'cat-jc'],
['piece-pixels', 'cat-reaction']
['piece-pixels', 'cat-reaction'],
['piece-inbox-link', 'cat-reaction'],
['piece-archive-demo', 'cat-reaction']
])
addCategory.run(...values);
@@ -173,7 +217,47 @@ export function seed(databasePath = getPaths().database) {
95,
0
],
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0]
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0],
[
'source-inbox-showcase',
'piece-inbox-link',
'Showcase-Aufzeichnung',
'https://example.com/showcase',
120,
0
]
]
);
replace(
'show_templates',
['id', 'name', 'description'],
[
['template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'],
['template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.']
]
);
replace(
'show_template_sections',
['id', 'template_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[
['template-default-start', 'template-default', 'Start', 'cat-jc', 0, 300],
[
'template-default-jc-reaction',
'template-default',
'JC / Reaction',
'cat-reaction',
1,
null
],
['template-default-game-1', 'template-default', 'Spiel 1', 'cat-game', 2, 3600],
['template-default-jc', 'template-default', 'JC', 'cat-jc', 3, null],
['template-default-game-2', 'template-default', 'Spiel 2', 'cat-game', 4, 3600],
['template-games-start', 'template-games', 'Start', 'cat-jc', 0, 300],
['template-games-game-1', 'template-games', 'Spiel 1', 'cat-game', 1, 2700],
['template-games-break', 'template-games', 'Pause & Chat', 'cat-jc', 2, 600],
['template-games-game-2', 'template-games', 'Spiel 2', 'cat-game', 3, 2700],
['template-games-game-3', 'template-games', 'Spiel 3', 'cat-game', 4, 2700]
]
);
@@ -186,6 +270,7 @@ export function seed(databasePath = getPaths().database) {
'twitch_title',
'announcement_text',
'status',
'revision',
'created_at_utc',
'updated_at_utc'
],
@@ -197,6 +282,18 @@ export function seed(databasePath = getPaths().database) {
'Kleine Welten, große Ideen | heinzlive',
'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.',
'ready',
1,
timestamp,
timestamp
],
[
'show-games-evening',
'Spieleabend: Kurbeln & Pixel',
'2026-10-16T18:00:00.000Z',
'Kurbeln, Pixel und ein langer Spieleabend | heinzlive',
'Heute spielen wir uns von ungewöhnlicher Hardware bis zu moderner Pixelart.',
'draft',
1,
timestamp,
timestamp
]
@@ -205,11 +302,25 @@ export function seed(databasePath = getPaths().database) {
replace(
'show_sections',
['id', 'show_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300],
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null],
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600]
'id',
'show_id',
'name',
'category_id',
'position',
'explicit_duration_seconds',
'is_reserve_bank'
],
[
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300, 0],
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null, 0],
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600, 0],
['reserve-bank-show-october', 'show-october', 'Reservebank', null, 3, null, 1],
['games-start', 'show-games-evening', 'Start', 'cat-jc', 0, 300, 0],
['games-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0],
['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 0],
['games-second', 'show-games-evening', 'Spiel 2', 'cat-game', 3, 2700, 0],
['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1]
]
);
@@ -224,9 +335,11 @@ export function seed(databasePath = getPaths().database) {
'duration_seconds',
'title_snapshot',
'intro_snapshot',
'research_snapshot',
'moderation_snapshot',
'transition_snapshot',
'presentation_snapshot',
'own_notes',
'is_reserve'
],
[
@@ -239,9 +352,11 @@ export function seed(databasePath = getPaths().database) {
480,
'Die kleine Konsole mit dem großen Dreh',
'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?',
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.',
'Von der kleinen Konsole geht es zur ganz großen Leinwand.',
'Weniger Leistung, mehr Idee.',
'',
0
],
[
@@ -253,24 +368,60 @@ export function seed(databasePath = getPaths().database) {
720,
'Warum praktische Effekte wieder auffallen',
'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.',
'',
'Beispiele vergleichen, nicht analog gegen digital ausspielen.',
'Nach so viel Handarbeit wird es spielerisch.',
'Wenn das Licht wirklich auf ein Modell fällt.',
'',
0
],
[
'usage-reserve',
'section-stories',
'reserve-bank-show-october',
'piece-pixels',
2,
0,
'open',
540,
'Pixelart, die nicht nostalgisch sein will',
'Pixel sind längst keine technische Notlösung mehr.',
'',
'Großaufnahmen vorbereiten, drei Stilmittel benennen.',
'Genug Theorie, jetzt spielen wir selbst.',
'Pixel als bewusste Entscheidung.',
'Ersatz, falls ein Spiel technisch ausfällt.',
1
],
[
'usage-games-console',
'games-first',
'piece-console',
0,
'open',
480,
'Die kleine Konsole mit dem großen Dreh',
'Wir beginnen mit Hardware, die ihr Spielprinzip gleich mitbringt.',
'Kurbelmechanik und verfügbare Spiele prüfen.',
'Konsole zeigen und Publikum nach Lieblingsmechaniken fragen.',
'Danach wechseln wir von Mechanik zu Grafik.',
'Eine Kurbel verändert, wie wir spielen.',
'Controller vor Sendungsbeginn laden.',
0
],
[
'usage-games-pixels',
'games-second',
'piece-pixels',
0,
'open',
540,
'Pixelart, die nicht nostalgisch sein will',
'Unser zweites Spiel nutzt Pixel nicht als Rückblick.',
'Drei aktuelle Beispiele vergleichen.',
'Auf Licht, Bewegung und Materialwirkung achten.',
'Zum Schluss sammeln wir eure Empfehlungen.',
'Pixel als bewusste Entscheidung.',
'',
0
]
]
);
+1186 -2
View File
File diff suppressed because it is too large. Load diff
@@ -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>
+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 pieces) AS pieces,
(SELECT count(*) FROM shows) AS shows,
(SELECT count(*) FROM piece_usages) AS usages`
(SELECT count(*) FROM piece_usages) AS usages,
(SELECT count(*) FROM show_templates) AS templates,
(SELECT count(*) FROM show_sections WHERE is_reserve_bank = 1) AS reserve_banks`
)
.get() as { migrations: number; pieces: number; shows: number; usages: number };
.get() as {
migrations: number;
pieces: number;
shows: number;
usages: number;
templates: number;
reserve_banks: number;
};
database.close();
expect(counts).toEqual({ migrations: 2, 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'));
});
+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' }
})
}
});
}
+42 -87
View File
@@ -1,4 +1,5 @@
import { getDatabase } from './database.js';
import { getShow as getShowById, listShows } from './shows.js';
export type PreviewPiece = {
id: string;
@@ -85,7 +86,7 @@ export function getPieces(): PreviewPiece[] {
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status != 'archived'
WHERE p.status IN ('preparing', 'ready')
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
)
.all() as PieceRow[];
@@ -104,96 +105,49 @@ export function getPieces(): PreviewPiece[] {
}));
}
type ShowRow = {
id: string;
title: string;
starts_at_utc: string;
twitch_title: string;
announcement_text: string;
status: string;
};
type SectionRow = {
id: string;
name: string;
category: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
};
type UsageRow = {
id: string;
show_section_id: string;
title_snapshot: string;
duration_seconds: number;
status: PreviewUsage['status'];
intro_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
is_reserve: number;
};
export function getShow(): PreviewShow {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status
FROM shows ORDER BY starts_at_utc DESC LIMIT 1`
)
.get() as ShowRow | undefined;
export function getShow(showId?: string): PreviewShow {
const latest = showId ? null : listShows().find((show) => show.startsAtUtc !== null);
const show = getShowById(showId ?? latest?.id ?? '');
if (!show) throw new Error('No preview show found. Run npm run db:seed.');
const sectionRows = database
.prepare(
`SELECT s.id, s.name, c.name AS category, c.color AS category_color,
s.explicit_duration_seconds
FROM show_sections s
LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
.all(show.id) as SectionRow[];
const usageRows = database
.prepare(
`SELECT id, show_section_id, title_snapshot, duration_seconds, status,
intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve
FROM piece_usages WHERE show_section_id IN (
SELECT id FROM show_sections WHERE show_id = ?
) ORDER BY position`
)
.all(show.id) as UsageRow[];
const sections = sectionRows.map((section) => {
const usages = usageRows
.filter((usage) => usage.show_section_id === section.id)
.map((usage) => ({
id: usage.id,
title: usage.title_snapshot,
durationSeconds: usage.duration_seconds,
status: usage.status,
intro: usage.intro_snapshot,
moderation: usage.moderation_snapshot,
transition: usage.transition_snapshot,
isReserve: usage.is_reserve === 1
}));
const plannedUsages = usages.filter((usage) => !usage.isReserve);
return {
const sections: PreviewSection[] = show.sections.map((section) => ({
id: section.id,
name: section.name,
category: section.category ?? 'Offen',
categoryColor: section.category_color ?? '#8b8f98',
durationSeconds:
section.explicit_duration_seconds ??
plannedUsages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
categoryColor: section.categoryColor ?? '#8b8f98',
durationSeconds: section.durationSeconds,
usages: section.usages.map((usage) => ({
id: usage.id,
title: usage.title,
durationSeconds: usage.durationSeconds,
status: usage.status,
intro: usage.intro,
moderation: usage.moderation,
transition: usage.transition,
isReserve: false
}))
}));
const reserveHost = sections[Math.min(1, sections.length - 1)];
if (reserveHost) {
reserveHost.usages.push(
...show.reserve.map((usage) => ({
id: usage.id,
title: usage.title,
durationSeconds: usage.durationSeconds,
status: usage.status,
intro: usage.intro,
moderation: usage.moderation,
transition: usage.transition,
isReserve: true
}))
);
}
return {
id: show.id,
title: show.title,
startsAtUtc: show.starts_at_utc,
startsAtBerlin: new Intl.DateTimeFormat('de-DE', {
startsAtUtc: show.startsAtUtc ?? '',
startsAtBerlin: show.startsAtUtc
? new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
weekday: 'short',
day: '2-digit',
@@ -201,12 +155,13 @@ export function getShow(): PreviewShow {
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(show.starts_at_utc)),
twitchTitle: show.twitch_title,
announcement: show.announcement_text,
}).format(new Date(show.startsAtUtc))
: 'Termin offen',
twitchTitle: show.twitchTitle,
announcement: show.announcementText,
status: show.status,
sections,
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
totalDurationSeconds: show.totalDurationSeconds
};
}
+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;
})();
}
+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 -6
View File
@@ -1,13 +1,35 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Archiv</h1>
<p class="page-intro">Abgeschlossene Sendungen in ihrem damaligen Stand.</p>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<h2>Noch keine Sendung archiviert</h2>
<p>Abgeschlossene Sendungen werden hier später dauerhaft auffindbar.</p>
</section>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
</div>
<form method="POST" action="?/restore">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<h2>Keine Pieces archiviert</h2>
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section>{/if}
</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;
+53 -6
View File
@@ -1,13 +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>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Eingang</h1>
<p class="page-intro">Neue Gedanken und Fundstücke vor der Übernahme in den Fundus.</p>
<p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<h2>Keine neuen Inhalte</h2>
<p>Hier erscheinen später neue Gedanken und ausgewählte Feed-Fundstücke.</p>
</section>
<section class="quiet-panel capture-panel">
<div>
<p class="eyebrow">Schnellaufnahme</p>
<h2>Was soll nicht verloren gehen?</h2>
</div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/capture" class="capture-form">
<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>
{#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>
+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() {
return { pieces: getPieces() };
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? ''
};
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;
+107 -55
View File
@@ -1,19 +1,9 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { resolve } from '$app/paths';
import { formatClock } from '#lib/format.js';
import type { PageData } 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);
})
);
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const statusLabel = {
ready: 'Sendefertig',
preparing: 'In Vorbereitung',
@@ -30,57 +20,125 @@
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
<div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<PreviewNotice area="Fundus" />
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern">
<label class="search-field">
<span aria-hidden="true">⌕</span>
<span class="sr-only">Fundus durchsuchen</span>
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" />
</label>
<div class="filter-tabs" aria-label="Status filtern">
<button class:active={filter === 'all'} type="button" onclick={() => (filter = 'all')}
>Alle</button
>
<button class:active={filter === 'ready'} type="button" onclick={() => (filter = 'ready')}
>Sendefertig</button
>
<button
class:active={filter === 'preparing'}
type="button"
onclick={() => (filter = 'preparing')}>In Arbeit</button
<details class="template-picker">
<summary>Neues Piece anlegen</summary>
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button
>
</form>
{/each}
</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">
{#each filteredPieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
{#each data.pieces as piece (piece.id)}
<article
class="piece-card"
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>{piece.title}</h2>
<p>{piece.summary}</p>
<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.category}</span>
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
<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"
>{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
>
<span class="piece-facts">
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</span>
</div>
<div class="piece-actions">
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
<form method="POST" action="?/duplicate">
<input type="hidden" name="pieceId" value={piece.id} /><button
class="text-button"
type="submit">Duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
</div>
</article>
{/each}
@@ -88,15 +146,9 @@
{:else}
<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Kein passendes Stück</h2>
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p>
<button
type="button"
onclick={() => {
query = '';
filter = 'all';
}}>Filter zurücksetzen</button
>
<h2>Kein passendes Piece</h2>
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div>
{/if}
</div>
+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';
export function load() {
const show = getShow();
export function load({ url }) {
const show = getShow(url.searchParams.get('show') || undefined);
const usages = show.sections.flatMap((section) =>
section.usages
.filter((usage) => !usage.isReserve)
@@ -11,5 +11,5 @@ export function load() {
0,
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 };
}
+7 -3
View File
@@ -29,14 +29,14 @@
aria-label="Schrift vergrößern">A+</button
>
</div>
<a href={resolve('/sendungen')}>Planung schließen</a>
<a href={resolve('/sendungen/[id]', { id: data.show.id })}>Planung schließen</a>
</div>
</header>
<PreviewNotice area="Live-Modus" />
<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">
<span>Aktuell · {data.current.section}</span><strong
>{formatClock(data.current.durationSeconds)}</strong
@@ -59,7 +59,11 @@
<span>Überleitung</span>
<p>{data.current.transition}</p>
</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">
<div class="next-card">
+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'
}
});
};
+72 -3
View File
@@ -1,5 +1,74 @@
import { getPieces, getShow } from '#lib/server/previews.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
export function load() {
return { pieces: getPieces(), show: getShow() };
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() };
};
export const actions = {
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const title = String(data.get('title') || '');
const startsAt = String(data.get('startsAt') || '');
const templateId = String(data.get('templateId') || '') || null;
let id: string;
try {
id = createShow({ title, startsAtUtc: berlinLocalToUtc(startsAt), templateId }).id;
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'INVALID_TITLE') {
return fail(400, { error: 'Bitte einen Titel eingeben.', title, startsAt, templateId });
}
if (code === 'INVALID_TIMESTAMP') {
return fail(400, {
error: 'Bitte einen gültigen Termin eingeben.',
title,
startsAt,
templateId
});
}
return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.',
title,
startsAt,
templateId
});
}
redirect(303, `/sendungen/${id}`);
}
} satisfies Actions;
+74 -100
View File
@@ -1,130 +1,104 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let plannedCount = $derived(
data.show.sections.reduce(
(total, section) => total + section.usages.filter((usage) => !usage.isReserve).length,
0
)
);
let { data, form }: PageProps = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<div class="page-wrap shows-page">
<header class="page-heading">
<div>
<h1>Sendungen</h1>
<p class="show-title">{data.show.title}</p>
<p class="show-date">{data.show.startsAtBerlin} Uhr</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve('/live')}>Live-Vorschau <span>↗</span></a>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
</div>
</header>
<PreviewNotice area="Sendungsbau" />
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button
class:active={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
>
<button class:active={mobilePane === 'plan'} type="button" onclick={() => (mobilePane = 'plan')}
>Ablauf</button
>
</div>
<div class="builder-workspace">
<section
class="builder-pane source-pane"
class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title"
>
<header class="pane-heading">
<section class="show-create-panel">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
<p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2>
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
<div>
<span class="category-label"><i></i>{piece.category}</span><span
>{formatClock(piece.durationSeconds)}</span
<form method="POST" action="?/create" class="show-create-form">
<label
>Titel<input
name="title"
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung"
/></label
>
</div>
<h3>{piece.title}</h3>
<p>{piece.format} · {piece.topics.join(', ')}</p>
</article>
{/each}
</div>
<div class="reserve-box">
<div>
<span aria-hidden="true">◇</span>
<div><strong>Reservebank</strong><small>1 Stück vorbereitet</small></div>
</div>
<p>„Pixelart, die nicht nostalgisch sein will“ wartet als Ersatz.</p>
<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>
<section
class="builder-pane run-pane"
class:mobile-hidden={mobilePane !== 'plan'}
aria-labelledby="run-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
</header>
<div class="run-list">
{#each data.show.sections as section, index (section.id)}
<article class="run-section" style={`--category: ${section.categoryColor}`}>
<section class="show-library" aria-labelledby="show-library-title">
<header>
<div class="section-number">{String(index + 1).padStart(2, '0')}</div>
<div>
<span class="category-label"><i></i>{section.category}</span>
<h3>{section.name}</h3>
</div>
<strong>{formatClock(section.durationSeconds)}</strong>
<h2 id="show-library-title">Geplante Sendungen</h2>
<span>{data.shows.length}</span>
</header>
{#if section.usages.filter((usage) => !usage.isReserve).length > 0}
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
{#if data.shows.length}
<div class="show-card-list">
{#each data.shows as show (show.id)}
<a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Stand für diese Sendung</small
<span class={`show-state ${show.status}`}
>{show.status === 'ready'
? 'Bereit'
: show.status === 'draft'
? 'Entwurf'
: show.status === 'live'
? 'Live'
: 'Abgeschlossen'}</span
>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
>{usage.status === 'current' ? 'Aktuell' : 'Offen'}</span
>
<div class="show-card-facts">
<span><strong>{show.sectionCount}</strong> Abschnitte</span><span
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
><b aria-hidden="true">→</b>
</div>
</a>
{/each}
</div>
{:else}
<div class="section-placeholder">Spieleblock ohne vorbereiteten Fundusinhalt</div>
{/if}
</article>
{/each}
<div class="empty-state">
<span aria-hidden="true">+</span>
<h2>Noch keine Sendung</h2>
<p>Oben kann der erste Stream geplant werden.</p>
</div>
{/if}
</section>
</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>
+40 -2
View File
@@ -1,6 +1,11 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getTaxonomy } from '#lib/server/previews.js';
import {
createTaxonomy,
deleteTaxonomy,
getTaxonomy,
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import {
inviteUser,
@@ -29,7 +34,12 @@ function actionError(cause: unknown): string {
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 'Das Konto wurde nicht gefunden.';
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.';
}
@@ -93,5 +103,33 @@ export const actions = {
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;
+71 -6
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
}
</script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
@@ -114,25 +118,86 @@
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2>
<div class="taxonomy-list">
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.categories as category (category.id)}<span
><i style={`--tax-color: ${category.color}`}></i>{category.name}</span
><i style={`--tax-color: ${category.color}`}></i>{category.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="category" /><input
type="hidden"
name="taxonomyId"
value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}>×</button>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="category" /><label
><span class="sr-only">Neue Abschnittskategorie</span><input
name="name"
required
maxlength="80"
placeholder="Neue Kategorie"
/></label
><label class="color-field"
><span class="sr-only">Farbe</span><input
name="color"
type="color"
value="#67DDE8"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2>
<div class="taxonomy-list">
{#each data.taxonomy.formats as format (format.id)}<span>{format.name}</span>{/each}
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.formats as format (format.id)}<span
>{format.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="format" /><input
type="hidden"
name="taxonomyId"
value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}>×</button>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="format" /><label
><span class="sr-only">Neues Format</span><input
name="name"
required
maxlength="80"
placeholder="Neues Format"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list">
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
<div class="taxonomy-list editable-taxonomy">
{#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>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label
><span class="sr-only">Neues Thema</span><input
name="name"
required
maxlength="80"
placeholder="Neues Thema"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
</div>
</div>
+1
View File
@@ -8,6 +8,7 @@ chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}"
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-6M}"
export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}"
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}"
+265 -28
View File
@@ -37,6 +37,12 @@ async function login(page: Page, email = adminEmail, password = adminPassword) {
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.');
@@ -178,23 +184,16 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const filtersBox = await page.locator('.filter-tabs').boundingBox();
expect(searchBox?.height).toBe(filtersBox?.height);
await expect(page.getByRole('button', { name: 'In Arbeit' })).toHaveCSS('white-space', 'nowrap');
const notice = page.locator('.preview-notice');
await expect(page.getByText('Vorschau · Fundus', { exact: true })).toBeVisible();
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeHidden();
await notice.locator('summary').focus();
await expect(notice.locator('summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeVisible();
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('button', { name: 'Anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.getByRole('button', { name: 'In Arbeit' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Stück' })).toBeVisible();
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click();
await page.getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Anwenden' }).click();
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();
@@ -210,11 +209,10 @@ test('desktop builder keeps the fundus beside the wider run sheet', async ({ pag
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/sendungen');
await openSeededShow(page);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible();
await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
@@ -235,7 +233,7 @@ test('desktop builder keeps the fundus beside the wider run sheet', async ({ pag
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
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();
@@ -253,7 +251,7 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
}, testInfo) => {
for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/sendungen');
await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
@@ -273,10 +271,139 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
}
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/sendungen');
await openSeededShow(page);
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
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 });
@@ -334,17 +461,16 @@ test('long titles and longer fundus lists reflow without overflow', async ({ pag
});
});
test('empty states and administration stay concise', async ({ page }) => {
test('inbox, archive and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
for (const route of ['/eingang', '/archiv']) {
await page.goto(route);
const emptyState = page.locator('.planned-panel');
await expect(emptyState).toBeVisible();
const box = await emptyState.boundingBox();
expect(box?.height ?? 999).toBeLessThan(150);
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 });
@@ -378,3 +504,114 @@ test('live preview retains its existing readable controls', async ({ page }) =>
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
});
});