6 Commits
Author SHA1 Message Date
hnzio 07f2c4607b Release 0.8.0 2026-10-27 14:57:17 +01:00
hnzio 21c0de7dd9 Release 0.7.3 2026-10-27 00:59:39 +01:00
hnzio d5b1c58ba2 Release 0.7.2 2026-10-26 21:20:55 +01:00
hnzio 64f55a8b1f Complete 0.7.1 fixes 2026-10-26 09:35:19 +01:00
hnzio a08b02c34b Release 0.7.1 2026-10-25 23:29:00 +01:00
hnzio 6d96900d81 Release 0.7.0 2026-10-25 18:40:01 +01:00
93 changed files with 9715 additions and 808 deletions

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ BODY_SIZE_LIMIT=6M
# Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN=
# Public production origins must use HTTPS; loopback HTTP remains available locally.
SENDEWERK_APP_ORIGIN=http://localhost:5173
# "local" writes messages to the outbox and never contacts a mail server.
+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.6.1",
"version": "0.8.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+60 -4
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 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen
persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine
abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6.
Etappe 1 bis 7: technisches Grundgerüst, Anmeldung und Rollen, Fundus,
Sendungsbau, Live-Steuerung, Präsentationsausgabe, Abschluss, Archiv,
Freigaben, RSS sowie technische REST- und MCP-Zugänge.
## Voraussetzungen
@@ -62,6 +62,9 @@ enthält bewusst kein Konto und kein Standardpasswort.
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`.
Eine öffentliche Produktions-Origin muss HTTPS verwenden. Nur für lokale
Loopback-Adressen bleibt HTTP zulässig.
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
@@ -151,6 +154,53 @@ DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale
Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
werden nicht übernommen.
## MCP und ChatGPT
Der lesende Streamable-HTTP-Endpunkt liegt unter `/mcp`. Seine konkrete
Adresse ist die konfigurierte Anwendungs-Origin mit angehängtem Pfad:
```text
https://<Cloudron-App-Domain>/mcp
```
Beispiel: Bei `SENDEWERK_APP_ORIGIN=https://sendewerk.example.org` lautet die
MCP-URL `https://sendewerk.example.org/mcp`. Niemals einen technischen
REST-Token an die URL hängen oder in ChatGPT eintragen. MCP verwendet einen
eigenen OAuth-2.1-Flow mit Authorization Code, PKCE und kurzlebigen Tokens.
Die Verbindung stellt ausschließlich lesende Werkzeuge bereit:
- `get_profile` identifiziert das verbundene Sendewerk-Konto ohne E-Mail-Adresse.
- `list_pieces` und `get_piece` lesen Fundus-Pieces.
- `list_shows` und `get_show_rundown` lesen Sendungen und deren Abläufe.
Die Detailwerkzeuge können private Recherche, Moderationsnotizen, Quellen,
eigene Notizen und Reserveinhalte liefern. Der angemeldete Nutzer muss diesem
Umfang ausdrücklich zustimmen. Sperrung oder Passwortwechsel widerrufen
bestehende OAuth-Tokens; jede MCP-Anfrage prüft Konto, Scope, Ablauf und
Zielressource erneut. MCP kann keine Inhalte schreiben.
### In ChatGPT einrichten
1. Zuerst Version `0.8.0` unter der endgültigen öffentlichen HTTPS-Origin
bereitstellen. Lokalhost ohne öffentliches HTTPS ist für eine direkte
ChatGPT-Verbindung nicht erreichbar.
2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und
**Add custom MCP server** öffnen.
3. Einen Namen und eine Beschreibung vergeben und unter **Connection** die
vollständige URL `https://<Cloudron-App-Domain>/mcp` eintragen.
4. OAuth als Authentifizierung verwenden, den Risikohinweis prüfen und
**Create as a plugin** wählen. ChatGPT entdeckt die OAuth-Endpunkte und
registriert einen öffentlichen PKCE-Client dynamisch.
5. Im geöffneten Sendewerk-Fenster mit dem eigenen Konto anmelden, Clientname
und Rücksprung-Domain prüfen und den lesenden Zugriff ausdrücklich erlauben.
6. Die gefundenen fünf Werkzeuge kontrollieren, das Plugin installieren und in
einer neuen Unterhaltung über `@` auswählen.
Eine erfolgreich lokale OAuth- und MCP-Prüfung beweist noch keine reale
ChatGPT-Verbindung. Diese muss nach dem Cloudron-Update separat in der
jeweiligen ChatGPT-Workspace-Konfiguration durchgeführt werden.
## Befehle
| Befehl | Zweck |
@@ -210,6 +260,12 @@ Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Seit Version `0.7.3`
führt der bestehende Startprozess Migration `0020_oauth_mcp.sql`
aus. Anschließend muss die von Cloudron gelieferte HTTPS-Origin mit der in
ChatGPT eingetragenen MCP-Domain übereinstimmen. Reverse Proxy und öffentliche
Erreichbarkeit wurden ohne Cloudron-Zugang nicht geprüft.
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden.
@@ -226,7 +282,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und
`better-sqlite3`, `@node-rs/argon2`, Tiptap, Marked, sanitize-html, file-type und
Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
installierten Baum.
+136 -1
View File
@@ -1,9 +1,144 @@
# Abnahme
## Redaktioneller Arbeitsbereich 0.8.0
| Bereich | Nachweis | Stand |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Gemeinsame Navigation | Chromium prüft Start, Eingang, Pieces, Streams und Live im Desktopkopf sowie als mobile Bottom-Navigation | erfüllt am 27.10.2026 |
| Dashboard ohne erfundene Daten | Root-Route zeigt nächsten Ablauf, aktuelle Pieces, Schnellaktionen und weitere Streams aus bestehenden Diensten | erfüllt am 27.10.2026 |
| Berechtigungssichere Suche | Servertests finden private Piece-Texte, archivierte Pieces und Stream-Snapshots; Browser prüft gruppierte Trefferseite | erfüllt am 27.10.2026 |
| Pieces und Abschluss | Chromium prüft automatisches Ausblenden, Abgeschlossen-Ansicht, verlinkte Historie und erneute Einplanung | erfüllt am 27.10.2026 |
| Streams und Historie | Filter leiten Angelegt, Vorbereitung, Sendefertig und Abgeschlossen ab; historische Abläufe bleiben unveränderlich | erfüllt am 27.10.2026 |
| Kontextnavigationen | Pieces und Streams besitzen lokale Suche und einklappbare Desktopnavigation; mobile Flächen bleiben überlauffrei | erfüllt am 27.10.2026 |
| Optionale rechte Arbeitsleiste | Usage-Auswahl öffnet bestehenden Editor nicht-modal rechts; Schließen, Speichern und mobile Vollbildansicht bleiben | erfüllt am 27.10.2026 |
| Daten- und Sicherheitsgrenzen | 62 Verhaltenstests sowie Browserabläufe für Rollen, Revisionen, Uploads, Live, Freigaben und Präsentation bleiben grün | erfüllt am 27.10.2026 |
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 62 von 62 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 21 von 21 Chromium-Abläufen erfolgreich.
- `git diff --check`: erfolgreich.
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
## Horizontale Fundus-Werkbank 0.7.4
| Bereich | Nachweis | Stand |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Horizontale Hauptnavigation | Chromium prüft Desktopkopf und getrennte mobile Navigation bei den vorhandenen Matrixbreiten | erfüllt am 27.10.2026 |
| Exakt drei Fundusspalten | Bounding-Box-Prüfung ordnet Navigation, Liste und Kontext bei 1440 Pixeln nebeneinander an | erfüllt am 27.10.2026 |
| Zustanderhaltende Navigation | Chromium behält `includePlanned` beim Wechsel Liste → Piece → Liste; Scrollzustand ist querygebunden implementiert | erfüllt am 27.10.2026 |
| Eingebetteter Piece-Editor | Schreiben liegt mittig, Metadaten rechts; Anlage, Autosave, Reload, Konflikt und Upload laufen ohne Desktopdialog | erfüllt am 27.10.2026 |
| Markdown und Rich Text | Gemeinsame Tiptap-Komponente in Original und Usage; Quellansicht speichert Markdown und sichere Vorschau bleibt bereinigt | erfüllt am 27.10.2026 |
| Bestehende Fachgrenzen | 60 Verhaltenstests sowie Browserabläufe für Snapshots, Werkzeuge, Live und Präsentation bleiben erfolgreich | erfüllt am 27.10.2026 |
| Responsive und Themes | Chromium-Matrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 27.10.2026 |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run lint`: erfolgreich.
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 20 von 20 Chromium-Abläufen erfolgreich.
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
## Lesender MCP-Zugang 0.7.3
| Bereich | Nachweis | Stand |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------- |
| OAuth-Discovery und anonymer Schutz | Reale lokale HTTP-Anfrage erhält 401 mit `resource_metadata`; Discovery-Routen sind im Produktionsbuild enthalten | erfüllt am 27.10.2026 |
| DCR, Zustimmung und PKCE | Isolierter HTTP-Ablauf registriert Client, lädt Zustimmung, erhält Code mit `state`/`iss` und tauscht per S256 | erfüllt am 27.10.2026 |
| Nutzer-, Scope- und Ressourcenbindung | Ausgestellter Token ist gehasht persistiert und wird bei jedem Aufruf gegen Nutzer, Scope, Ablauf und `/mcp` geprüft | erfüllt am 27.10.2026 |
| MCP-Kompatibilität | Legacy-Initialisierung `2025-11-25` sowie `server/discover` und `tools/list` mit `2026-07-28` über HTTP | erfüllt am 27.10.2026 |
| Tatsächliche Leseanfrage | `tools/call` auf `list_pieces` liefert aus einer temporären Seed-Datenbank reale Pieces | erfüllt am 27.10.2026 |
| Schreibschutz und Datenminimierung | Nur fünf Lese-Tools; Positivprojektionen ohne E-Mail, Uploadnamen, Dateipfade und Werkzeug-Arbeitsdaten | erfüllt am 27.10.2026 |
| ChatGPT- und Cloudron-Verbindung | Kein Deployment und keine externe Clientumgebung vorhanden | offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- Gezielter isolierter HTTP-Ablauf: OAuth Code Flow, PKCE, anonymer Challenge,
Legacy-Initialisierung, aktuelle Discovery, Toolliste und echte
`list_pieces`-Anfrage erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- Keine weitere Verhaltenstest- oder Browsermatrix gemäß Auftrag ausgeführt;
die Zustimmungsseite wurde im HTTP-Ablauf geladen, aber nicht visuell geprüft.
- Echte ChatGPT-Verbindung, öffentliche HTTPS-Erreichbarkeit und Cloudron-
Reverse-Proxy-Verhalten bleiben bis zu einem ausdrücklich freigegebenen
Deployment offen.
## Wartungsrelease 0.7.2
| Bereich | Nachweis | Stand |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| RSS-Auswahl im Eingang (#16) | Chromium wählt zwei Beiträge über Quellenfilter und zwei Seiten, öffnet den Originallink ohne Umschalten und importiert nur die Auswahl | erfüllt am 26.10.2026 |
| Live-Menü nach Nutzerabsicht (#18) | Chromium prüft laufenden, pausierten und beendeten Zustand, direkte Timersteuerung, eingeklappte Ausgabe und erreichbare Navigation | erfüllt am 26.10.2026 |
| RSS-/API-Verwaltung im Katalogstil (#21) | Chromium legt einen Feed an und bearbeitet ihn; API-Zugang, einmaliges Tokenkopieren und Berechtigungsänderung geprüft | erfüllt am 26.10.2026 |
| Responsive Gestaltung und Farbschemata | RSS-Auswahl und Feed-Dialog mobil/dunkel, Verwaltung desktop/hell, Live-Menü mobil und bei 670 × 450 Pixeln geprüft | erfüllt am 26.10.2026 |
| Erhalt von Daten, Rechten und Sicherheitslogik | 13 gezielte Feed-/Live-/Zugangstests sowie vollständiger Verhaltenstestlauf; keine Schemaänderung | erfüllt am 26.10.2026 |
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 20 von 20 Chromium-Abläufen erfolgreich.
- Andere Browser und echte Mobilgeräte: offen.
## Wartungsrelease 0.7.1
| Bereich | Nachweis | Stand |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Abschnittsdauer und Rückkehr zu früheren Pieces (#1) | Live-Verhaltenstests für geänderte Plandauer, Wiederaufnahme, Abschnittslaufzeit und Gesamtzeit; Chromium-Ablauf | erfüllt am 26.10.2026 |
| Mobiler Live-Ablauf und Metadaten (#3, #13) | mobile Live-Regie ohne horizontalen Überlauf; viewportgebundenes Sheet, Safe-Area-/Standalone-Basis und Fokus in Chromium | Chromium erfüllt; Safari und installierte iOS-Webapp offen |
| Mobile Sendungskarten (#5) | responsive Karten im vollständigen Chromium-Lauf bei 390 Pixeln geöffnet | erfüllt am 26.10.2026 |
| Vorbereitung nach Einplanung (#6, #15) | Migration und Servertest für konkrete Usage-Snapshots; Chromium öffnet die Usage und entfernt sie nach „sendefertig“ | erfüllt am 26.10.2026 |
| Kontextabhängige Präsentation (#8) | vier serverseitig getrennte Modi; Fundus-/Usage-Vorschauen sowie Link-, Video-, Text-/Bildausgabe in Chromium | erfüllt am 26.10.2026 |
| Gastansicht und Freigaben (#11, #12) | gestalteter Einzeldialog, Verwaltungsreihenfolge und Kartenpadding per Chromium; Tokenersatz mit Invalidierungstest | erfüllt am 26.10.2026 |
| Iconsuche (#14) | Suchfokus innerhalb der öffnenden Bedienaktion; Chromium bei mobilen und breiten Ansichten | erfüllt am 26.10.2026 |
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 19 von 19 Chromium-Abläufen erfolgreich.
- Safari auf echtem iOS-Gerät: offen.
- Installierte Webapp auf echtem iOS-Gerät: separat offen.
## Etappe 7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Feedverwaltung und geplanter/manueller Abruf | Barkeeper-Oberfläche, Prozess-Scheduler und gemeinsame Abruffunktion | erfüllt am 25.10.2026 |
| Getrennter Vorschau-Eingang | RSS-Beiträge erscheinen separat und werden nur nach Mehrfachauswahl zu Pieces | erfüllt am 25.10.2026 |
| Kategoriezuordnung und Herkunft | Auswahlkategorien oder Feed-Standard sowie Original-URL als Piece-Quelle im Verhaltenstest | erfüllt am 25.10.2026 |
| Dubletten und dauerhaftes Verwerfen | wiederholter Testabruf erzeugt keinen zweiten Eintrag und stellt Verworfenes nicht wieder her | erfüllt am 25.10.2026 |
| HTML-Bereinigung und Abrufgrenzen | Testfeed entfernt fremdes HTML; Loopback wird vor Request blockiert; Größe, Zeit, Redirects und Eintragszahl sind begrenzt | erfüllt am 25.10.2026 |
| Fehlerisolation | defekter lokaler Testfeed wird protokolliert, weiterer fälliger Feed trotzdem importiert | erfüllt am 25.10.2026 |
| Versionierte REST-API und OpenAPI | `/api/v1` für Pieces, Sendungen und Einplanungen; `/api/v1/openapi.json` | erfüllt am 25.10.2026 |
| Begrenzte und widerrufbare Zugänge | gehashte Tokens; Verhaltenstest für Lesen, verweigertes Schreiben und sofortigen Widerruf | erfüllt am 25.10.2026 |
| Änderungsnachweis und Idempotenz | Audit-Ereignisse und persistente Idempotenz; identische Wiederholung liefert dasselbe Ergebnis | erfüllt am 25.10.2026 |
| API-Rundlauf | echter HTTP-Test: lesen, anlegen, ändern, erneut lesen, einplanen und sortieren | erfüllt am 25.10.2026 |
| Nutzung ohne KI | RSS und API sind optional; bestehende Redaktion funktioniert unverändert ohne externen Client | erfüllt am 25.10.2026 |
| Konkrete ChatGPT-/Codex-Anbindung | keine Clientumgebung oder Zugangsdaten vorhanden | offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 58 von 58 Verhaltenstests erfolgreich.
- Gezielte Etappe-7-Tests: 6 von 6 erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm audit --omit=dev`: 0 gemeldete Schwachstellen.
- Gezielte Chromium-Prüfung: Desktop-Verwaltung, mobiler Eingang und kompletter
HTTP-API-Rundlauf erfolgreich, keine Konsolenfehler.
- `npm run test:e2e`: auf dem Standardport wegen eines bereits laufenden fremden
Prozesses nicht startbar; identische temporäre Konfiguration auf Port 4188 mit
16 von 18 erfolgreichen Abläufen. Bekannte Fehler bleiben bei Live-Zeitanzeige
und Abschluss/Freigabe bestehen.
- `npm run lint`: nicht erfolgreich wegen acht bereits vorhandener
Prettier-Abweichungen außerhalb der Etappe-7-Dateien; geänderte Dateien wurden
separat formatiert.
- Dokumentationsstatus: **API/MCP getestet, Client-Anbindung offen**. Konkret ist
die REST-API getestet; ein zusätzlicher MCP-Adapter wurde nicht benötigt und
keine echte ChatGPT-/Codex-Verbindung hergestellt.
## Wartungsrelease 0.6.1
| Bereich | Stand |
| --- | --- |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Live-Timer, Kopfzeile, Drawer und Positionsanzeige | behoben |
| Mobile Formulare und Iconsuche | behoben |
| Vorbereitungsaufwand und Übersicht | behoben |
+169 -22
View File
@@ -3,7 +3,7 @@
## Systemgrenze
Sendewerk ist ein einzelner SvelteKit-Prozess. Browserseiten, serverseitige
Actions, spätere REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe
Actions, REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe
Geschäftslogik. SQLite und ein lokales Upload-Verzeichnis sind die einzigen
persistenten Laufzeitabhängigkeiten. Externe Mail-, RSS- oder KI-Dienste sind
nicht Voraussetzung für Kernfunktionen.
@@ -11,7 +11,7 @@ nicht Voraussetzung für Kernfunktionen.
```text
Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite
| -> Upload-Verzeichnis
+-> später: Mail/RSS über Adapter
+-> Mail/RSS über begrenzte Adapter
```
Servercode lebt unter `src/lib/server` und darf nicht in Browser-Bundles
@@ -19,6 +19,41 @@ gelangen. Routen liefern nur die für ihre Ansicht erlaubten Daten. Fachlogik
wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die
spätere API dieselben Regeln verwenden.
## Navigation, Suche und Arbeitsflächen
`+layout.svelte` stellt für angemeldete Nutzer genau die globalen Bereiche Start,
Eingang, Pieces, Streams und Live bereit. Desktop verwendet eine horizontale
Kopfzeile, Mobilgeräte eine obere Such-/Kontoleiste und eine Safe-Area-fähige
Bottom-Navigation. `UserMenu.svelte` bündelt Verwaltung, Abmeldung und die weiter
in `localStorage` gespeicherte explizite Auswahl Hell, Dunkel oder System.
Die Root-Route komponiert das Dashboard ausschließlich aus `listShows()`,
`getShow()`, `listPieces()` und den vorhandenen Verwendungskontexten. Sie besitzt
keinen eigenen persistenten Zustand. Pieces und Streams verwenden getrennte,
lokal gespeicherte Ein-/Ausklappzustände ihrer Kontextnavigation; URL-Parameter
bleiben die fachliche Quelle für lokale Suche und Filter.
`global-search.ts` ist eine serverseitige, ausschließlich hinter `requireUser`
verwendete Projektion. Sie durchsucht aktive und archivierte Pieces einschließlich
redaktioneller Langtexte sowie Metadaten und Usage-Snapshots aller Streams. Die
Route `/suche` gibt gruppierte Positivprojektionen mit ID, Titel, Status, internem
Ziel, Feldbezeichnung und begrenztem Ausschnitt aus. Gelöschte Pieces werden nicht
global angeboten. Es gibt keinen externen Index und keine neue Persistenz.
Die normale Piece-Arbeitsmenge schließt Originale mit materialisierter
Verwendungshistorie aus. Dieselben Originale werden unter `view=completed` mit
ihren verlinkten Streams angezeigt und bleiben über die bestehenden
Planungsfilter erneut einplanbar. Abgeschlossene Streams werden nicht mehr als
eigener globaler Archivbereich behandelt, sondern von `listShows()` in der
Streamübersicht gefiltert. Der technische `/archiv`-Pfad bleibt für archivierte
und gelöschte Pieces bestehen.
Der Usage-Editor verwendet weiterhin `PieceEditorDialog` und dieselben
Form-Actions. Auf breiten Ansichten öffnet das Dialogelement nicht-modal als
rechte Arbeitsleiste; ohne ausgewählte Usage wird es nicht gerendert. Unterhalb
der mobilen Grenze öffnet dieselbe Komponente modal und nutzt den verfügbaren
Viewport.
## Laufzeit und Speicher
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
@@ -33,13 +68,13 @@ spätere API dieselben Regeln verwenden.
## Gesamtdatenmodell
Die ersten sechs Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung, Live-Durchführung, Historie und Lesefreigaben. Folgende Grenzen
gelten für umgesetzte und spätere Bereiche.
Die ersten sieben Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung, Live-Durchführung, Historie, Lesefreigaben, RSS-Import und eine
technische API. 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 |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sowie getrennte technische Zugänge |
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
@@ -99,6 +134,43 @@ 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.
Technische Zugänge sind keine Browsersitzungen. Sie besitzen zufällige
Bearer-Tokens, von denen nur SHA-256-Hashes gespeichert werden, und genau einen
der Umfänge `read` oder `read_write`. Widerruf wirkt beim nächsten Request.
`/api/v1` umgeht lediglich den Browser-Loginredirect; jeder Endpunkt prüft Token
und erforderlichen Umfang selbst. Schreibzugriffe hinterlassen ein
`audit_event`; Neuanlage und Planungsänderungen verlangen zusätzlich einen je
Zugang eindeutigen, persistent gespeicherten Idempotenzschlüssel.
## RSS-Import
`src/lib/server/feeds.ts` bildet Abruf, XML-Verarbeitung und Übernahme gemeinsam
für manuelle Aktionen und den Prozess-Scheduler ab. Der Scheduler startet beim
ersten Request und prüft minütlich höchstens zehn fällige aktive Feeds. Jeder
Feed wird getrennt behandelt, sodass ein Fehler weitere Abrufe nicht stoppt.
HTTP-Abrufe erlauben nur HTTP(S) auf Standardports, prüfen DNS sowie jedes
Redirectziel gegen interne Adressen und begrenzen Laufzeit, Redirects und
Antwortgröße. RSS/Atom wird mit deaktivierter Entity-Verarbeitung gelesen;
höchstens 200 Einträge je Antwort werden berücksichtigt und fremdes HTML wird zu
reinem Text reduziert.
`feed_entries` ist ein eigener Eingang. `(feed_id, external_id)` verhindert
Dubletten; Aktualisierungen ändern den redaktionellen Status nicht. `discarded`
bleibt deshalb auch nach späteren Abrufen erhalten. Erst die bewusste
Mehrfachauswahl erzeugt innerhalb einer Transaktion normale Inbox-Pieces mit
Kategorien und Original-URL als Quelle und setzt den Eintrag auf `imported`.
## Technische REST-API
Die Routen unter `/api/v1` bilden keine zweite Fachlogik, sondern rufen
`content.ts` und `shows.ts` auf. Piece- und Sendungsrevisionen schützen weiterhin
vor verlorenen Änderungen. `api_idempotency` speichert Request-Hash, Status und
JSON-Antwort, sodass wiederholte Schreibbefehle dasselbe Ergebnis liefern und
ein gleicher Schlüssel mit verändertem Inhalt abgewiesen wird. Die OpenAPI-3.1-
Beschreibung ist unter `/api/v1/openapi.json` erreichbar. Ein MCP-Adapter oder
produktspezifischer KI-Client gehört nicht zur Laufzeit und ist nicht
Voraussetzung für die Bedienung.
## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
@@ -343,12 +415,12 @@ Status und Termin.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Auf dem Desktop liegt sie als feste horizontale Glasleiste oberhalb der
Arbeitsfläche. Bis einschließlich `52rem` ersetzen eine kompakte obere Leiste
und die untere Hauptnavigation diesen Desktopkopf vollständig. Auf breiten
Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem`
werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch
schmale Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
@@ -385,17 +457,28 @@ Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog
nicht in ihren Client-Chunk.
Der Fundus ist auf breiten Ansichten ein Raster aus Bibliotheksnavigation,
mittlerer Liste beziehungsweise Piece-Schreibfläche und rechter Kontextspalte.
Kleinere Desktopfenster blenden zuerst den rechten Kontext aus; mobil wird die
Bibliotheksnavigation zur horizontalen Auswahl und im geöffneten Editor ganz
ausgeblendet. URL-Parameter tragen Suche und Filter über den Routenwechsel,
`sessionStorage` ausschließlich die zugehörige flüchtige Listenposition.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
Arbeitsblatt ohne Desktopdialog an. Titel, Einleitung, Moderationsstichpunkte,
Interaktion, Überleitung, private Recherche, Quellen und Werkzeuge bilden die
mittlere Schreibfläche. Status, Dauer, Einordnung, Präsentationsmodi und Medien
liegen auf breiten Ansichten rechts. Alle Werte werden weiterhin gemeinsam über
die bestehende `save`-Action und Piece-Revision gespeichert; separate Bild- und
Präsentationsactions bleiben eigene serverseitige Mutationen.
`RichMarkdownField` erstellt je sichtbarem Langtext genau eine Tiptap-Instanz mit
StarterKit und offizieller Markdown-Erweiterung. Der Formularwert bleibt rohes
Markdown. Eine Bearbeitung aktualisiert ihn aus `getMarkdown()`, während das
bloße Initialisieren keinen Input auslöst. Weicht die erste Serialisierung vom
gespeicherten Text ab, startet das Feld in der Markdown-Quellansicht. Der Nutzer
kann diese Ansicht jederzeit erreichen; Autosave erzeugt die Editorinstanz nicht
neu.
## Sicherheitsgrenzen
@@ -413,3 +496,67 @@ bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium.
## Wartungskorrekturen 0.7.1
Migration `0019` ergänzt `preparation_status_snapshot` und
`preparation_effort_snapshot` an `piece_usages` und übernimmt für vorhandene
Einplanungen einmalig die damaligen Originalwerte. Danach ist ausschließlich die
konkrete Sendungsfassung maßgeblich. Einplanen, Duplizieren und bewusstes
Aktualisieren aus dem Fundus kopieren beide Werte; der Usage-Editor ändert sie
unabhängig. Die Übersicht berücksichtigt vorbereitende Nicht-Reserve-Usages
aktiver Sendungen, priorisiert zuerst deren Termin und führt direkt zum Editor.
Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben
Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und
alle bisherigen Gastsitzungen werden in derselben Transaktion ungültig. Der neue
Klartexttoken wird weiterhin nur in der Action-Antwort ausgegeben.
## Bedienkorrekturen 0.7.2
Der RSS-Auswahldialog erhält weiterhin ausschließlich `new`-Einträge aus
`listFeedEntries`. Quellenfilter, Seiteneinteilung und noch nicht bestätigte
Auswahl sind flüchtiger Browserzustand. Erst das Formular im erreichbaren
Dialogfuß übergibt die gewählten IDs an die unveränderte transaktionale
Übernahme; dadurch bleiben persistenter Dublettenschutz, Herkunft und
Standardkategorien die einzige fachliche Wahrheit.
Feed- und Zugangsdialoge verwenden dieselben globalen Management-Dialog- und
Katalogstile wie Werkzeuge. Das Bearbeiten eines technischen Zugangs ändert nur
Name und Umfang des bestehenden gehashten Tokens und wird auditiert. Die
Live-Route projiziert zusätzlich nur, ob der feste Ausgabekanal getrennt, mit
dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und
Ausgabedetails führt selbst keine Mutation aus.
## Lesender MCP-Zugang 0.7.3
`/mcp` ist ein stateless Streamable-HTTP-Adapter über `content.ts` und
`shows.ts`. Er unterstützt die aktuelle per Request deklarierte MCP-Fassung
`2026-07-28` einschließlich `server/discover`, Header-/Body-Abgleich und
`resultType`. Für derzeitige OpenAI-Clients bleiben die Initialisierungsfassungen
`2025-11-25`, `2025-06-18` und `2025-03-26` verfügbar. Es gibt keine
MCP-Sitzung und keinen unabhängigen SSE-Strom; jede Anfrage trägt erneut ihre
Authentifizierung.
Die fünf Werkzeuge sind ausschließlich lesend. Eigene Positivprojektionen
lassen Mediennamen, interne Speicherpfade und Werkzeug-Arbeitsdaten aus. Die
Detailprojektionen dürfen nach ausdrücklicher Zustimmung redaktionell private
Recherche, Moderation, eigene Notizen, Quellen und Reserveinhalte enthalten.
Keine MCP-Methode ruft Mutationsdienste auf.
Sendewerk ist zugleich OAuth-Autorisierungs- und Ressourcenserver. Geschützte
Ressourcen- und Authorization-Server-Metadaten verweisen ausschließlich auf die
konfigurierte Origin. Öffentliche PKCE-Clients werden per begrenztem DCR
registriert; die Autorisierung verlangt exakte Redirect-URI, `S256`, den Scope
`sendewerk:read` und den exakten Ressourcenindikator `<Origin>/mcp`. Codes sind
fünf Minuten, Zugriffstokens eine Stunde gültig. SQLite speichert nur SHA-256-
Hashes. Tokens sind direkt an Nutzer, Client, Scope und Ressource gebunden;
Kontosperre und Passwortwechsel widerrufen sie, und jeder MCP-Aufruf verbindet
erneut den aktuellen Nutzerzustand.
DCR ist für die von OpenAI weiterhin unterstützte Kompatibilität vorhanden,
aber pro Quelladresse und insgesamt begrenzt. Clientnamen sind nicht verifiziert;
der Zustimmungsdialog kennzeichnet dies und zeigt die genaue Rücksprung-Domain.
Öffentliche Produktion verlangt HTTPS, während HTTP nur an Loopback-Adressen
zulässig bleibt. Der bestehende technische REST-Token wird weder gelesen noch
weitergereicht.
+161
View File
@@ -459,3 +459,164 @@ Bildmodus verwendet ein separates boolesches Kennzeichen, damit bestehende
Tabellen und Daten trotz älterer Modus-Check-Constraints ohne riskanten
Tabellenumbau erhalten bleiben. Freigabeverwaltung bleibt eine gemeinsame
Komponente und öffnet in allen drei internen Einstiegen als nativer Dialog.
## D-029: RSS als persistenter Eingang mit begrenztem Abruf
Externe Feeds schreiben nie direkt in den Fundus. Quelle und stabiler
Eintragsschlüssel werden in einem eigenen Eingang gespeichert; dessen Zustand
`discarded` oder `imported` wird bei einem erneuten Abruf nicht zurückgesetzt.
Erst eine bewusste Auswahl erzeugt über `content.ts` ein normales Piece samt
Originalquelle und gewählten Kategorien. Dadurch bleiben redaktionelle
Entscheidung, Dublettenschutz und Herkunft nachvollziehbar.
Manueller und geplanter Abruf verwenden denselben Adapter. Wie bei
Linkmetadaten werden private Ziele, Nichtstandardports und ungeprüfte Redirects
gesperrt; zusätzlich gelten enge Zeit-, Größen- und Mengenlimits. XML-Entities
bleiben deaktiviert und fremdes HTML wird nicht gespeichert. Ein Fehler wird am
betroffenen Feed persistiert und unterbricht keine weiteren fälligen Feeds.
## D-030: Technische Tokens, Scopes und persistente Idempotenz
Externe Assistenz erhält keine simulierte Redaktionssitzung und keine
Sonderrechte. Eigene zufällige Bearer-Tokens liegen nur gehasht vor, sind sofort
widerrufbar und erlauben entweder Lesen oder Lesen und Schreiben. REST-Routen
unter `/api/v1` verwenden die bestehenden Inhalts- und Sendungsdienste, damit
Validierung, Revisionen und Transaktionen identisch bleiben. Jede erfolgreiche
Mutation wird mit technischem Akteur, Aktion und Ziel protokolliert.
Neuanlage, Einplanung und Sortierung verlangen einen `Idempotency-Key`.
Zugang, Schlüssel, Hash des Requestinhalts und vollständige Antwort werden in
derselben SQLite-Transaktion gespeichert. Eine identische Wiederholung erhält
dieselbe Antwort; ein veränderter Request mit demselben Schlüssel wird
abgewiesen. OpenAPI 3.1 beschreibt diese Grenze maschinenlesbar. Ein MCP-Adapter
oder ein bestimmter KI-Client wird nicht vorgetäuscht: Die Anwendung bleibt ohne
KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft
werden.
## D-031: Usage-basierte Vorbereitung und rotierende Freigabelinks
Die Sendungsübersicht erzeugt keinen zusätzlichen Vorbereitungszustand. Sie
projiziert konkrete Usage-Snapshots aktiver Sendungen, deren Fundus-Original noch
`preparing` ist. So bleiben bestehende Daten und Snapshots unverändert, während
ungeplante Fundus-Arbeit aus der Sendungsübersicht verschwindet.
Weil Freigabetokens nur gehasht gespeichert werden, kann ein bestehender Link
nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben
Freigabe transaktional und löscht alle Gastsitzungen; Sichtumfang, Passwort und
Ablauf werden nicht stillschweigend verändert.
## D-032: Vorbereitung als Eigenschaft der konkreten Einplanung
Die erste 0.7.1-Umsetzung aus D-031 leitete den Vorbereitungszustand noch vom
veränderlichen Fundus-Original ab. Das widerspricht der bestehenden Kopierregel
für Sendungsfassungen und verhindert unterschiedliche Arbeitsstände bei
Mehrfachverwendung. D-032 ersetzt deshalb diesen Teil von D-031: Status und
Aufwand werden mit Migration `0019` additiv an `piece_usages` gespeichert.
Vorbereitende und sendefertige Originale dürfen eingeplant werden. Einplanung,
Duplikation und ausdrückliches Aktualisieren kopieren den Zustand, während der
Usage-Editor ihn danach unabhängig ändert. `piece_usages.status` bleibt davon
getrennt und beschreibt weiterhin ausschließlich den Livezustand `open`,
`current`, `done` oder `skipped`.
## D-033: Auswahlzustand flüchtig, Verwaltungslogik gemeinsam
Quellenfilter, Pagination und Mehrfachauswahl des RSS-Dialogs bleiben lokaler
UI-Zustand. Persistiert wird erst durch die bestehende transaktionale
Importaktion, damit Blättern oder Öffnen eines externen Links niemals
redaktionelle Inhalte erzeugt. Die Verwaltung ruft weiterhin dieselben Feed-
und Zugangsdienste auf; neue Dialoge ersetzen nur die dauerhaft ausgebreiteten
Formulare. Ein technischer Zugang darf Name und Umfang ändern, behält dabei aber
seinen ausschließlich gehasht gespeicherten Token und erzeugt ein Auditereignis.
Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals.
Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen
verändern öffentliche Ausgabe. Navigation zur Planung bleibt davon getrennt.
## D-034: Nutzergebundener OAuth-Zugang statt Token-Passthrough
Der bestehende technische REST-Zugang kann und darf nicht für ChatGPT
weitergereicht werden: Sein Klartexttoken ist nach der Erstellung nicht mehr
verfügbar, und MCP verbietet Token-Passthrough. Sendewerk stellt deshalb einen
eigenen OAuth-2.1-Authorization-Code-Flow für öffentliche Clients bereit. PKCE
`S256`, exakte Redirect-URIs und der auf `<Origin>/mcp` gebundene
Ressourcenindikator sind verpflichtend. Codes und einstündige Zugriffstokens
liegen ausschließlich gehasht vor und sind direkt an ein aktives Nutzerkonto
gebunden.
Die Autorisierungsserver-Metadaten bieten das von OpenAI unterstützte Dynamic
Client Registration als Kompatibilitätsweg an. Die aktuelle MCP-Fassung stuft
DCR zugunsten von Client ID Metadata Documents als veraltet ein. Ein allgemeiner
CIMD-Abruf würde jedoch eine neue SSRF- und Cache-Sicherheitsgrenze eröffnen;
für diese private Anwendung bleibt DCR deshalb eng mengen- und ratenbegrenzt.
Der Zustimmungsdialog behandelt den selbst gemeldeten Clientnamen ausdrücklich
als unverifiziert und zeigt dessen Rücksprung-Domain.
Der MCP-Adapter unterstützt sowohl die aktuelle stateless Fassung `2026-07-28`
als auch die von OpenAI weiterhin dokumentierten Initialisierungsfassungen bis
`2025-03-26`. Tooldaten werden aus bestehenden Diensten positiv projiziert.
Der einzige Scope `sendewerk:read` ist im Dialog ausdrücklich einschließlich
privater redaktioneller Recherche beschrieben; E-Mail, Uploadnamen,
Speicherpfade und Werkzeug-Arbeitsdaten werden nicht ausgegeben. Alle Werkzeuge
sind als lesend markiert und haben keinen Pfad zu Mutationsdiensten.
Quellen, geprüft am 27. Oktober 2026:
- <https://developers.openai.com/apps-sdk/build/auth>
- <https://developers.openai.com/apps-sdk/deploy/connect-chatgpt>
- <https://platform.openai.com/docs/mcp>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning>
- <https://modelcontextprotocol.io/specification/2026-07-28/server/discover>
- <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
## D-035: Horizontale Fundus-Werkbank und Markdown als Editorgrenze
Die Desktopnavigation wird horizontal, damit der Fundus die gesamte Breite als
drei fachlich eindeutige Spalten nutzen kann: Navigation, Liste oder Schreiben,
sowie Metadaten und Präsentation. Das Öffnen eines Pieces wechselt nur den
mittleren Arbeitskontext; Filter bleiben URL-Zustand, die Listenposition bleibt
flüchtiger Browserzustand. Bei weniger Platz verschwindet zuerst die rechte
Kontextspalte. Mobil wird nicht das Desktopgitter gestapelt, sondern zwischen
kompakten, einzeln bedienbaren Ansichten gewechselt.
Tiptap ist ausschließlich eine Browser-Bearbeitungsschicht. Markdown bleibt
persistentes und öffentliches API-Format, die vorhandene serverseitige
Marked-/sanitize-html-Grenze bleibt unverändert. `@tiptap/core`, `@tiptap/pm`,
`@tiptap/starter-kit` und `@tiptap/markdown` werden versionsgleich in 3.32.0
verwendet und sind MIT-lizenziert. Weil die Markdown-Erweiterung noch als frühe
Veröffentlichung dokumentiert ist, löst Initialisierung keine Speicherung aus;
abweichende Roundtrips öffnen die jederzeit erreichbare Quellansicht statt den
gespeicherten Text still umzuschreiben.
Quellen, geprüft am 27. Oktober 2026:
- <https://tiptap.dev/docs/editor/getting-started/install/svelte>
- <https://tiptap.dev/docs/editor/markdown/getting-started/basic-usage>
- npm-Metadaten der vier Tiptap-Pakete 3.32.0
## D-036: Begriffe und Navigation sind Projektionen, keine neuen Fachmodelle
Die sichtbaren Begriffe Pieces und Streams ersetzen Fundus und Sendungen in der
Bedienoberfläche. Persistierte Tabellen, Statuswerte, API-Pfade und bestehende
Browserrouten werden nicht umbenannt: Eine rein visuelle Neuordnung rechtfertigt
weder eine riskante Datenmigration noch einen Bruch externer Verträge.
Start ist eine aus vorhandenen Diensten abgeleitete Projektion. Die globale
Suche bleibt ein synchroner serverseitiger SQLite-Leseweg ohne eigenen Index.
Nur angemeldete Nutzer erreichen sie; Treffer enthalten kurze Ausschnitte statt
vollständiger Dokumente. Lokale Bereichssuchen und URL-Filter bleiben davon
unabhängig.
Bereits gesendete Pieces bilden keinen neuen persistenten Status. Wie bisher ist
die materialisierte Verwendungshistorie die Quelle; sie bestimmt nun zusätzlich
die sichtbare Gruppe Abgeschlossen. Ebenso werden die sichtbaren Streamzustände
Angelegt und In Vorbereitung aus `draft` plus vorhandener Abschnittszahl
abgeleitet. Die bestehenden Status- und Snapshotregeln bleiben damit alleinige
Fachquelle.
Der rechte Usage-Editor ist auf Desktop ein nicht-modales Dialogelement. Dadurch
bleiben dieselbe Komponente, Validierung und Form-Action erhalten, während der
Ablauf daneben bedienbar bleibt. Mobil verwendet dieselbe Komponente weiterhin
einen modalen Vollbildkontext.
+158
View File
@@ -1,5 +1,163 @@
# Release Notes
## 0.8.0 - Redaktioneller Arbeitsbereich
Veröffentlicht am 27. Oktober 2026
- Die globale Navigation besteht aus Start, Eingang, Pieces, Streams und Live.
Globale Suche und ein gemeinsames Benutzermenü ergänzen Desktopkopf und mobile
Suchleiste; Theme, Verwaltung und Abmeldung sind dort explizit erreichbar.
- Die neue Startseite zeigt ausschließlich vorhandene redaktionelle Daten: den
nächsten Stream mit Ablauf, aktuelle Pieces, Schnellaktionen und weitere
geplante Streams.
- Die angemeldeten Nutzern vorbehaltene globale Suche gruppiert Pieces und
Streams, berücksichtigt archivierte beziehungsweise abgeschlossene Inhalte
sowie private Redaktionstexte und zeigt kurze Trefferkontexte.
- Pieces und Streams besitzen einklappbare Kontextnavigationen und unabhängige
lokale Suchen. Gesendete Pieces liegen automatisch unter Abgeschlossen;
abgeschlossene Streams bleiben samt gespeichertem Ablauf unter Streams.
- Die Piece-Übersicht nutzt kompakte vollständig anklickbare Zeilen ohne
Bildplatzhalter oder leere rechte Kontextspalte. Der Usage-Editor öffnet auf
Desktop als nicht-modale rechte Arbeitsleiste und mobil als Vollbilddialog.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.0`. Es gibt keine
Datenbankmigration und keine Änderung persistierter Status- oder API-Verträge.
- Lint, Typprüfung, 62 Verhaltenstests, Produktionsbuild und 21
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
mangels Umgebung offen.
## 0.7.4 - Horizontale Fundus-Werkbank
Veröffentlicht am 27. Oktober 2026
- Die globale Desktopnavigation verläuft horizontal. Der Fundus verwendet die
freie Breite als dreispaltige Werkbank aus Navigation, Liste beziehungsweise
Editor sowie Metadaten und öffentlicher Präsentation.
- Der Piece-Editor ersetzt beim Öffnen die mittlere Liste und ist kein
Desktopdialog mehr. Filterparameter und Listenposition bleiben bei der
Rückkehr erhalten; schmalere und mobile Ansichten reduzieren die Spalten
kontrolliert statt sie vollständig untereinander zu stapeln.
- Fundus- und Sendungsfassungen teilen einen Tiptap-Editor für Langtexte.
Markdown bleibt Speicher- und API-Format. Eine jederzeit erreichbare
Quellansicht schützt Inhalte, die sich nicht verlustfrei visuell abbilden
lassen; das bloße Öffnen erzeugt keine Speicherung.
- Bestehende Revisions-, Autosave-, Quellen-, Werkzeug-, Medien-, Snapshot- und
Präsentationsgrenzen bleiben unverändert. Die rechte Präsentationsfläche nutzt
weiterhin den gemeinsamen sicheren Renderer und verändert keinen OBS-Kanal.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.4`; es gibt keine
Datenbankmigration. Die vier offiziellen Tiptap-Pakete sind auf Version 3.32.0
gepinnt und MIT-lizenziert.
- Lint, Typprüfung, 60 Verhaltenstests, Produktionsbuild und 20
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
mangels Umgebung offen.
## 0.7.3 - Lesender MCP-Zugang mit OAuth 2.1
Veröffentlicht am 27. Oktober 2026
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` bietet fünf ausschließlich
lesende Werkzeuge für Kontoidentität, Pieces, Sendungen und vollständige
Abläufe. Er verwendet die bestehenden Inhalts- und Sendungsdienste und keine
parallele Datenhaltung.
- OAuth-2.1-Discovery, begrenzte dynamische Clientregistrierung,
Authorization Code, PKCE `S256`, exakte Redirect-URI und Ressourcenbindung
ermöglichen die von OpenAI dokumentierte Kontoverknüpfung. Codes und
einstündige Tokens werden nur gehasht gespeichert.
- Nutzerzustand, Sperren und Rollen werden bei jedem MCP-Aufruf erneut gelesen.
Passwortwechsel und Kontosperre widerrufen bestehende OAuth-Tokens. Der
vorhandene technische REST-Token wird weder offengelegt noch weitergereicht.
- MCP `2026-07-28` und die weiterhin von OpenAI verwendeten älteren
Initialisierungsfassungen werden am selben stateless Endpunkt unterstützt.
Private redaktionelle Details sind erst nach ausdrücklicher Zustimmung
lesbar; E-Mail, Uploadnamen, interne Pfade und Werkzeug-Arbeitsdaten bleiben
ausgeschlossen.
- Die Produktions-URL lautet `https://<Cloudron-App-Domain>/mcp`. README und
Betriebsdokumentation beschreiben die Einrichtung über **Add custom MCP
server**. Das Release wurde nicht auf Cloudron ausgerollt und eine reale
ChatGPT-Verbindung wird nicht als geprüft ausgegeben.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.3`. Migration
`0020_oauth_mcp.sql` ergänzt additive OAuth- und Ratenbegrenzungstabellen.
- Typprüfung und Produktionsbuild sind erfolgreich. Ein isolierter lokaler
HTTP-Rundlauf hat OAuth, beide MCP-Protokollgenerationen und eine echte
`list_pieces`-Anfrage geprüft; eine reale ChatGPT-/Cloudron-Verbindung bleibt
ausdrücklich offen.
## 0.7.2 - RSS-Auswahl, Live-Menü und Schnittstellenverwaltung
Veröffentlicht am 26. Oktober 2026
- Der Eingang zeigt neue RSS-Beiträge in einer kompakten Katalogfläche. Ein
großzügiger, mobil vollflächiger Dialog filtert mehrere Quellen vor der
Seiteneinteilung, zeigt 20 Beiträge je Seite und behält die barrierefrei
erkennbare Auswahl über Quellen- und Seitenwechsel. Erst die ausdrückliche
Übernahme erzeugt Eingangspieces; Herkunft, Kategorien, Verwerfen und
Dublettenschutz bleiben erhalten (#16).
- Das Live-Menü folgt der Nutzerabsicht: Sendungssteuerung, kompakte Ansicht,
Lesefreigaben, standardmäßig eingeklappte OBS-Ausgabe und getrennte Navigation
sind klar geordnet. Pause/Fortsetzen sowie Abschnitts- und Gesamtreset liegen
direkt bei den Timern. Laufend, pausiert und beendet werden eindeutig
unterschieden; Navigation allein verändert weder Lauf noch Ausgabe (#18).
- RSS-Feeds und technische API-Zugänge verwenden Panel, Typografie,
Eintragskarten, Classic-Regular-Icons und Dialograhmen des Werkzeugkatalogs.
Feeds und Zugänge lassen sich in Dialogen anlegen und bearbeiten; Abruf,
Löschen beziehungsweise Widerruf bleiben kompakte Iconaktionen. Neue
API-Tokens erscheinen unmittelbar einmalig und kopierbar, während weiterhin
nur ihr Hash gespeichert wird (#21).
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.2`. Es ist keine
Datenbankmigration erforderlich. 60 Verhaltenstests und 20 Chromium-Abläufe
sind erfolgreich; echte Browser-/Geräteprüfungen außerhalb von Chromium
bleiben offen.
## 0.7.1 - Bedienung und Live-Korrekturen
Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026
- Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder
übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre
Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1).
- Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare
Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13).
- „Noch vorzubereiten“ speichert Status und Aufwand an der konkreten Einplanung,
priorisiert aktive Sendungen nach Termin und verlinkt mit den Themenicons direkt
in den Usage-Editor. Vorbereitende Originale können regulär eingeplant werden;
Mehrfachverwendungen bleiben unabhängig (#6, #15).
- Fundus- und Usage-Editor unterscheiden Text, Nur Bild, Linkvorschau und Video
durch kontextsensitive Eingaben und passende Vorschauen (#8).
- Gast- und historische Abschnitte besitzen klar gepolsterte äußere Glaskarten.
Die Freigabeverwaltung liegt direkt vor dem Werkzeugkatalog; der gemeinsame
Dialog und seine Formfelder folgen ohne nativen Doppelrahmen dem Sendewerk-
Design. Einmalige Links lassen sich kopieren und sicher ersetzen (#11, #12).
- Die Iconsuche ist nach dem Öffnen sofort sichtbar und fokussiert (#14).
- RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte
Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog.
- Mobiles Live-Sheet und Backdrop sind am Viewport verankert. Viewport-Fit,
Standalone-Metadaten und Web-App-Manifest aktivieren iOS-Safe-Areas; ein echter
Safari-/Home-Bildschirm-Test bleibt mangels Gerät offen (#3, #13).
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Migration
`0019_usage_preparation.sql` ergänzt additive Usage-Snapshots. 60
Verhaltenstests und 19 Chromium-Abläufe sind
erfolgreich; echtes Safari und die installierte Webapp auf iOS bleiben offen.
## 0.7.0 - RSS-Import und externe Assistenz
Veröffentlicht am 25. Oktober 2026
- Barkeeper verwalten RSS-/Atom-Feeds mit Intervall und Standardkategorie;
sichere manuelle und geplante Abrufe laufen fehlerisoliert.
- Neue Beiträge landen in einem getrennten Auswahlbereich im Eingang. Erst eine
bewusste Übernahme erzeugt Pieces, während dauerhaftes Verwerfen, Herkunft,
Kategorien und Dublettenschutz erhalten bleiben.
- Abrufe sperren interne Ziele und begrenzen Ports, Redirects, Laufzeit,
Antwortgröße und Eintragszahl. Fremdes Feed-HTML wird zu Text bereinigt.
- Die versionierte REST-API `/api/v1` liest und ändert Pieces, Sendungen und
Einplanungen über getrennte technische Lese- oder Schreibzugänge.
- Technische Tokens werden nur einmal gezeigt und gehasht gespeichert. Widerruf,
Audit-Ereignisse, Revisionen und persistente Idempotenz schützen Änderungen.
- `/api/v1/openapi.json` beschreibt die Schnittstelle für externe Clients.
Sendewerk bleibt ohne KI nutzbar; eine konkrete ChatGPT-/Codex-Anbindung ist
weiterhin offen.
- Migration `0018_rss_and_api.sql` ergänzt Feed-, Zugangs-, Audit- und
Idempotenzdaten. Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.0`.
## 0.6.1 - Redaktionelle Wartung
Veröffentlicht am 25. Oktober 2026
+173 -2
View File
@@ -2,10 +2,181 @@
## Aktueller Umfang
Etappe 1 bis Etappe 6 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Etappe 1 bis Etappe 7 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv und sichere Lesefreigaben sind dokumentiert.
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert.
## Redaktioneller Arbeitsbereich 0.8.0
- Die sichtbare Anwendungssprache verwendet durchgehend Pieces und Streams; die
stabilen technischen Routen und Datenmodelle bleiben unverändert. Desktop und
Mobilgerät teilen die fünf Hauptbereiche Start, Eingang, Pieces, Streams und
Live. Suche, explizite Theme-Auswahl, Verwaltung und Abmeldung liegen in der
gemeinsamen Kopfzeile beziehungsweise im Benutzermenü.
- `/` ist ein echtes Dashboard aus vorhandenem nächsten Stream, dessen kompaktem
Ablauf, aktuellen Pieces, Schnellaktionen und weiteren geplanten Streams. Es
erzeugt weder Aufgaben noch andere redaktionelle Daten.
- Die globale, angemeldeten Nutzern vorbehaltene Suche gruppiert Treffer nach
Pieces und Streams. Sie berücksichtigt archivierte Pieces, abgeschlossene
Streams, private redaktionelle Langtexte und eingefrorene Stream-Snapshots und
liefert nur einen kurzen Trefferkontext mit Fundort.
- Pieces und Streams besitzen einklappbare Kontextnavigationen mit lokaler Suche.
Bereits gesendete Pieces verschwinden aus dem normalen Arbeitsbestand und
liegen mit verlinkter Verwendungshistorie unter Abgeschlossen; archivierte und
gelöschte Pieces bleiben unter Pieces erreichbar. Abgeschlossene Streams sind
über den Streamfilter erreichbar und behalten den gespeicherten Ablauf.
- Die Piece-Liste ist eine kompakte, vollständig anklickbare Zeilenansicht ohne
erzwungene Bildplatzhalter. Ohne Auswahl existiert keine rechte Leerspalte. In
der Streamplanung öffnet der bestehende vollständige Usage-Editor auf Desktop
als nicht-modale rechte Arbeitsleiste und mobil weiterhin als Vollbilddialog.
- Für diese Änderung war keine Datenbankmigration nötig. Rollen, Rechte,
Revisionsschutz, Snapshotgrenzen, Markdown-Speicherung und bestehende Actions
bleiben erhalten.
Geprüft wurden 62 von 62 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
21 von 21 Chromium-Abläufe. Dashboard, globale Suche, abgeschlossene Pieces und
Streams, Desktop-/Mobilnavigation, rechte Usage-Arbeitsleiste sowie die
bestehenden Rollen-, Live-, Freigabe-, Autosave- und Präsentationsabläufe waren
erfolgreich. Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
## Horizontale Fundus-Werkbank 0.7.4
- Die allgemeine Desktopnavigation verläuft horizontal über der Anwendung. Der
Fundus nutzt darunter eine dreispaltige Werkbank aus Bibliotheksnavigation,
Liste beziehungsweise Schreibfläche sowie Kontext und Präsentation. Bei
kleinerem Platz fällt zuerst die Kontextspalte weg; mobil werden Navigation
und Editor gezielt einzeln statt als gestauchte Dreispaltenansicht gezeigt.
- Fundusnavigation, Status, Favoriten, Archiv, Papierkorb und Themen zeigen aus
den bestehenden Daten abgeleitete Zähler. Suche, Taxonomiefilter,
Verwendungsfilter und Listenposition bleiben beim Wechsel in ein Piece und
zurück erhalten.
- Der Fundus-Editor ist auf Desktop kein Dialog mehr. Sendungstext und private
Recherche stehen dauerhaft in der mittleren Schreibfläche; Status, Dauer,
Einordnung und die öffentliche Präsentationsfläche liegen ausschließlich
rechts. Bestehende Autosave-, Revisions-, Quellen-, Werkzeug-, Medien- und
Präsentationsactions bleiben unverändert getrennt.
- Alle formatierbaren Langtexte in Fundus- und Sendungsfassung verwenden eine
gemeinsame Tiptap-Komponente. Gespeichert und an Form-Actions übermittelt wird
weiterhin Markdown. Eine erreichbare Quellansicht übernimmt nicht verlustfrei
abbildbares Markdown, ohne es bereits beim Öffnen umzuschreiben.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und die
gezielten Chromium-Abläufe für Fundus, Piece-Autosave, Sendungsfassung,
Werkzeuge, Live-Snapshots sowie Link-/Videopräsentation. Die abschließende
vollständige Browsermatrix ist im Abnahmenachweis festgehalten.
## Lesender MCP-Zugang 0.7.3
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und
Abläufe über dieselben Dienste wie Browser und REST-API. Fünf deklarierte
Werkzeuge besitzen nur Leseannotationen und keine Mutationspfade.
- OAuth 2.1 verwendet Discovery-Metadaten, begrenzte dynamische
Clientregistrierung, Authorization Code, PKCE `S256`, exakte Redirect-URIs
und Ressourcenbindung. Codes und einstündige Tokens liegen nur gehasht vor.
- OAuth-Tokens sind an den zustimmenden Nutzer gebunden. Sperrung und
Passwortwechsel widerrufen sie; jeder Zugriff prüft erneut Nutzerstatus,
Scope, Ablauf und Zielressource.
- Aktuelles MCP `2026-07-28` mit `server/discover` und per-Request-Metadaten
läuft neben den OpenAI-kompatiblen Initialisierungsfassungen. Ausgaben sind
Positivlisten ohne E-Mail, Uploadnamen, interne Pfade oder Werkzeug-Arbeitsdaten.
- Die konkrete Produktionsadresse ist
`https://<Cloudron-App-Domain>/mcp`. Ein Cloudron-Deployment und eine reale
ChatGPT-Verbindung wurden nicht durchgeführt.
Geprüft wurden Typen, Produktionsbuild und ein isolierter tatsächlicher
HTTP-Ablauf über eine temporäre Datenbank: anonymer OAuth-Challenge, DCR,
Zustimmung, PKCE-Tokenaustausch, Legacy-Initialisierung, aktuelle
`server/discover`-Abfrage, Toolliste und `list_pieces` mit Seed-Daten waren
erfolgreich. Eine weitere Verhaltenstest- oder Browsermatrix wurde gemäß
Auftrag nicht ausgeführt.
## Bedienkorrekturen 0.7.2
- Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl
öffnet einen responsiven Dialog mit kombinierbaren Quellenfiltern, 20 Beiträgen
pro Seite, tastaturbedienbaren Auswahlkarten und einer festen bewussten
Übernahme. Auswahl bleibt beim Filtern und Blättern erhalten; importierte oder
verworfene Beiträge verschwinden über den bestehenden persistenten Status.
- Das Live-Menü trennt Sendungssteuerung, Ansicht, Lesefreigaben,
OBS-Ausgabekanal und Navigation. Pause/Fortsetzen und beide Timerresets liegen
bei den Zeitflächen. Der Menüstatus unterscheidet Entwurf, Bereit, Läuft,
Pausiert, Lauf beendet und historischen Abschluss; der OBS-Bereich bleibt bis
zur bewussten Aktion eingeklappt.
- Feedquellen und technische Zugänge verwenden dieselben Katalogkarten und
Dialoggrundlagen wie Werkzeuge. Beide lassen sich anlegen und bearbeiten;
Feeds behalten Abruf/Löschen, Zugänge ihren sofortigen Widerruf. Ein neuer
API-Token wird im Dialog einmalig angezeigt und kann direkt kopiert werden.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
20 von 20 Chromium-Abläufe. Der neue Ablauf prüft RSS-Auswahl über mehrere
Quellen und Seiten, Übernahme ohne erneutes Angebot, Feed- und Zugangsanlage
samt Bearbeitung und Tokenkopie. Das Live-Menü wurde laufend, pausiert und
beendet sowie bei kleinem Desktopfenster und mobil geprüft. Die betroffenen
Flächen wurden in hellem und dunklem Farbschema kontrolliert. Andere Browser und
echte Mobilgeräte bleiben offen.
## Fehlerkorrekturen 0.7.1
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst
wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten.
- Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, lange
Metadaten verbreitern die Regie nicht mehr und das Sheet ist am mobilen
Viewport verankert. `viewport-fit=cover`, Standalone-Metadaten und Manifest
aktivieren Safe Areas auch beim Start vom Home-Bildschirm. Sendungskarten
stapeln ihre Inhalte auf kleinen Bildschirmen eindeutig.
- Die Vorbereitung liegt als eigener Status- und Aufwandsnapshot an der konkreten
Einplanung. Vorbereitende Fundus-Pieces lassen sich planen; Originaländerungen,
Duplikation und bewusste Aktualisierung folgen der bestehenden Snapshotregel.
Usage-Titel, Sendung, Abschnitt, Termin und Themenicons führen direkt in den
Usage-Editor; „sendefertig“ entfernt die Fassung aus der Übersicht.
- Text, Nur Bild, Linkvorschau und Video besitzen in Fundus- und Usage-Editor
kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim
Moduswechsel erhalten.
- Die Iconsuche fokussiert innerhalb der öffnenden Klick-/Tastaturaktion das
Suchfeld. RSS-Feeds und technische Zugänge verwenden gestaltete Formfelder.
- Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren
Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den
alten Token und dessen Gastsitzungen. Die Verwaltung zeigt Freigaben direkt
vor dem Werkzeugkatalog; der Dialog besitzt nur eine gestaltete Glasfläche.
Gast- und historische Abschnitte besitzen äußere Karten mit Innenabstand.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
19 von 19 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie
bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem
echten iOS-Gerät bleiben mangels Gerät getrennt offen.
## RSS-Import und externe Assistenz 0.7.0
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und
Abrufintervall; manueller Abruf und ein begrenzter Prozess-Scheduler verwenden
dieselbe Fehler- und Dublettenlogik.
- Feedabrufe prüfen Protokoll, Port, DNS und Redirects, begrenzen Laufzeit,
Antwortgröße und Eintragszahl und lassen Fehler anderer Feeds unberührt.
- Fremdes HTML wird entfernt. Beiträge bleiben mit stabiler Herkunft im
getrennten Importeingang, bis die Redaktion sie auswählt, kategorisiert und
als normale Pieces übernimmt oder dauerhaft verwirft.
- `/api/v1` bietet Pieces und Sendungen über gehashte, widerrufbare technische
Bearer-Tokens mit reinen Lese- oder Lese-/Schreibrechten an. Mutationen nutzen
bestehende Dienste, Audit-Ereignisse, Revisionen und persistente
Idempotenzschlüssel.
- Die maschinenlesbare Beschreibung liegt unter `/api/v1/openapi.json`; eine
ChatGPT-/Codex- oder andere konkrete Client-Anbindung wurde nicht eingerichtet.
- Migration `0018_rss_and_api.sql` ergänzt ausschließlich additive Tabellen und
Indizes. Sendewerk bleibt ohne KI und ohne konfigurierte Feeds vollständig
nutzbar.
Geprüft wurden 58 Verhaltenstests, Typprüfung, Produktionsbuild, Dependency-
Audit, vier RSS- und zwei Zugangssicherheitstests sowie ein echter HTTP-Rundlauf
lesen → anlegen → idempotent wiederholen → ändern → erneut lesen → einplanen →
sortieren. Desktop-Verwaltung und mobiler Eingang wurden in Chromium ohne
Konsolenfehler geprüft. In der bestehenden Gesamtmatrix waren 16 von 18 Abläufen
erfolgreich; die bereits bekannte Live-Zeitanzeige und der Ablauf
Abschluss/Freigabe schlugen weiterhin fehl. Der globale Lintlauf bleibt an acht
vorhandenen Formatierungsabweichungen außerhalb dieser Etappe offen.
## Wartungsrelease 0.6.1
+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE feed_sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1 CHECK (is_enabled IN (0, 1)),
refresh_interval_minutes INTEGER NOT NULL DEFAULT 60 CHECK (refresh_interval_minutes BETWEEN 15 AND 10080),
default_category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
etag TEXT NOT NULL DEFAULT '',
last_modified TEXT NOT NULL DEFAULT '',
last_fetched_at_utc TEXT,
next_fetch_at_utc TEXT,
last_error TEXT NOT NULL DEFAULT '',
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE feed_entries (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL REFERENCES feed_sources(id) ON DELETE CASCADE,
external_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
published_at_utc TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'imported', 'discarded')),
piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
first_seen_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL,
UNIQUE (feed_id, external_id)
);
CREATE INDEX feed_entries_status_idx ON feed_entries(status, first_seen_at_utc DESC);
CREATE INDEX feed_sources_due_idx ON feed_sources(is_enabled, next_fetch_at_utc);
CREATE TABLE technical_accesses (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
permissions TEXT NOT NULL CHECK (permissions IN ('read', 'read_write')),
created_by_user_id TEXT NOT NULL REFERENCES users(id),
created_at_utc TEXT NOT NULL,
last_used_at_utc TEXT,
revoked_at_utc TEXT
);
CREATE TABLE api_idempotency (
access_id TEXT NOT NULL REFERENCES technical_accesses(id) ON DELETE CASCADE,
request_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (access_id, request_key)
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'technical')),
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
CREATE INDEX audit_events_created_idx ON audit_events(created_at_utc DESC);
+17
View File
@@ -0,0 +1,17 @@
ALTER TABLE piece_usages ADD COLUMN preparation_status_snapshot TEXT NOT NULL DEFAULT 'ready'
CHECK (preparation_status_snapshot IN ('preparing', 'ready'));
ALTER TABLE piece_usages ADD COLUMN preparation_effort_snapshot TEXT
CHECK (preparation_effort_snapshot IS NULL OR preparation_effort_snapshot IN ('low', 'medium', 'high'));
UPDATE piece_usages
SET preparation_status_snapshot = CASE
WHEN EXISTS (
SELECT 1 FROM pieces p
WHERE p.id = piece_usages.original_piece_id AND p.status = 'preparing'
) THEN 'preparing'
ELSE 'ready'
END,
preparation_effort_snapshot = (
SELECT p.preparation_effort FROM pieces p WHERE p.id = piece_usages.original_piece_id
);
+49
View File
@@ -0,0 +1,49 @@
CREATE TABLE oauth_clients (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
redirect_uris_json TEXT NOT NULL CHECK (json_valid(redirect_uris_json)),
created_at_utc TEXT NOT NULL
);
CREATE TABLE oauth_authorization_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
resource TEXT NOT NULL,
code_challenge TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
consumed_at_utc TEXT
);
CREATE INDEX oauth_authorization_codes_expiry_idx
ON oauth_authorization_codes(expires_at_utc);
CREATE TABLE oauth_access_tokens (
token_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
resource TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
last_used_at_utc TEXT,
request_window_started_at_utc TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0),
revoked_at_utc TEXT
);
CREATE INDEX oauth_access_tokens_user_idx ON oauth_access_tokens(user_id);
CREATE INDEX oauth_access_tokens_expiry_idx ON oauth_access_tokens(expires_at_utc);
CREATE TABLE oauth_rate_limit_attempts (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('registration', 'token')),
address_hash TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX oauth_rate_limit_attempts_lookup_idx
ON oauth_rate_limit_attempts(kind, address_hash, attempted_at_utc);
+688 -2
View File
@@ -1,15 +1,20 @@
{
"name": "sendewerk",
"version": "0.6.1",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.6.1",
"version": "0.8.0",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"@tiptap/core": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"metascraper": "^5.58.5",
@@ -599,6 +604,18 @@
"node": ">= 22.13"
}
},
"node_modules/@nodable/entities": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@node-rs/argon2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
@@ -1221,6 +1238,404 @@
"vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@tiptap/core": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz",
"integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz",
"integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz",
"integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz",
"integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz",
"integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz",
"integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz",
"integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz",
"integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz",
"integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz",
"integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz",
"integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz",
"integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz",
"integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz",
"integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz",
"integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz",
"integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz",
"integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz",
"integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz",
"integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz",
"integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz",
"integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz",
"integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/markdown": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.32.0.tgz",
"integrity": "sha512-y+pGAu4QMkXBMxhBoLvc6FWucQ1EWIoPi+Ajf4rAh5OlZtSV3CjBwyK6/xTwO7mvQxtoUf7DD23CzSaj5nCTBA==",
"license": "MIT",
"dependencies": {
"marked": "^17.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/markdown/node_modules/marked": {
"version": "17.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
"integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@tiptap/pm": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.4",
"prosemirror-commands": "^1.7.2",
"prosemirror-dropcursor": "^1.8.4",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.1",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.12",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.2",
"prosemirror-view": "^1.42.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz",
"integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/extension-blockquote": "3.32.0",
"@tiptap/extension-bold": "3.32.0",
"@tiptap/extension-bullet-list": "3.32.0",
"@tiptap/extension-code": "3.32.0",
"@tiptap/extension-code-block": "3.32.0",
"@tiptap/extension-document": "3.32.0",
"@tiptap/extension-dropcursor": "3.32.0",
"@tiptap/extension-gapcursor": "3.32.0",
"@tiptap/extension-hard-break": "3.32.0",
"@tiptap/extension-heading": "3.32.0",
"@tiptap/extension-horizontal-rule": "3.32.0",
"@tiptap/extension-italic": "3.32.0",
"@tiptap/extension-link": "3.32.0",
"@tiptap/extension-list": "3.32.0",
"@tiptap/extension-list-item": "3.32.0",
"@tiptap/extension-list-keymap": "3.32.0",
"@tiptap/extension-ordered-list": "3.32.0",
"@tiptap/extension-paragraph": "3.32.0",
"@tiptap/extension-strike": "3.32.0",
"@tiptap/extension-text": "3.32.0",
"@tiptap/extension-underline": "3.32.0",
"@tiptap/extensions": "3.32.0",
"@tiptap/pm": "3.32.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
@@ -1630,6 +2045,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -2689,6 +3116,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.1",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3055,6 +3521,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/is-uri": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/is-uri/-/is-uri-1.2.14.tgz",
@@ -3502,6 +3980,12 @@
"node": ">=10"
}
},
"node_modules/linkifyjs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz",
"integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==",
"license": "MIT"
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -3910,6 +4394,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4052,6 +4542,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4309,6 +4814,145 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4422,6 +5066,12 @@
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -4545,6 +5195,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
"integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/strtok3": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
@@ -5170,6 +5835,12 @@
"node": ">=12.20.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
@@ -5284,6 +5955,21 @@
"node": ">=18"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+6 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.6.1",
"version": "0.8.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
@@ -25,7 +25,12 @@
},
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"@tiptap/core": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"metascraper": "^5.58.5",
+1665 -6
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -8,6 +8,7 @@ declare global {
interface PageData {
user?: AuthUser | null;
presentationMode?: boolean;
oauthMode?: boolean;
}
}
}
+5 -1
View File
@@ -2,8 +2,12 @@
<html lang="de" data-theme="light" data-theme-preference="system">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f4f1eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Sendewerk" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<script>
(function () {
try {
+16 -1
View File
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
import { startFeedScheduler } from '#lib/server/feeds.js';
const publicPaths = new Set([
'/',
@@ -14,9 +15,15 @@ const publicPaths = new Set([
]);
export const handle: Handle = async ({ event, resolve }) => {
startFeedScheduler();
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const apiPath = event.url.pathname.startsWith('/api/v1/');
const oauthPath =
event.url.pathname.startsWith('/oauth/') ||
event.url.pathname.startsWith('/.well-known/oauth-');
const mcpPath = event.url.pathname === '/mcp';
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
@@ -26,6 +33,9 @@ export const handle: Handle = async ({ event, resolve }) => {
!presentationPath &&
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!apiPath &&
!oauthPath &&
!mcpPath &&
!event.locals.user
) {
redirect(303, hasUsers() ? '/login' : '/setup');
@@ -62,7 +72,12 @@ export const handle: Handle = async ({ event, resolve }) => {
);
}
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
if (
event.url.pathname === '/einladung' ||
event.url.pathname === '/passwort-zuruecksetzen' ||
oauthPath ||
mcpPath
) {
response.headers.set('Cache-Control', 'no-store');
}
return response;
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Feed = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
};
let { categories }: { categories: { id: string; name: string }[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let feed = $state<Feed | null>(null);
let name = $state('');
let url = $state('');
let refreshIntervalMinutes = $state(60);
let defaultCategoryId = $state('');
let isEnabled = $state(true);
export async function showModal(selectedFeed: Feed | null = null) {
feed = selectedFeed;
name = selectedFeed?.name ?? '';
url = selectedFeed?.url ?? '';
refreshIntervalMinutes = selectedFeed?.refreshIntervalMinutes ?? 60;
defaultCategoryId = selectedFeed?.defaultCategoryId ?? '';
isEnabled = selectedFeed?.isEnabled ?? true;
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog feed-editor-dialog"
aria-labelledby="feed-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
<div>
<p class="eyebrow">RSS-Quelle</p>
<h2 id="feed-dialog-title">{feed?.name || 'Feed hinzufügen'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Feeddialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form class="management-dialog-form" method="POST" action="?/saveFeed">
{#if feed}<input type="hidden" name="feedId" value={feed.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Feed-URL<input bind:value={url} name="url" type="url" required maxlength="2000" /></label
>
<label
>Intervall in Minuten<input
bind:value={refreshIntervalMinutes}
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
required
/></label
>
<label
>Standardkategorie<select bind:value={defaultCategoryId} name="defaultCategoryId"
><option value="">Keine</option>{#each categories as category (category.id)}<option
value={category.id}>{category.name}</option
>{/each}</select
></label
>
</div>
<div class="management-dialog-selections">
<label class="toggle-row"
><input type="checkbox" name="isEnabled" value="1" bind:checked={isEnabled} /><span
><strong>Automatischer Abruf</strong><small
>Der Feed wird im festgelegten Intervall sicher abgerufen.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{feed ? 'Änderungen speichern' : 'Feed anlegen'}</button
>
</footer>
</form>
</dialog>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- all links are closed local routes assembled below */
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
filters,
counts,
topics
}: {
filters: { query: string; status: string; topicId: string; favorites: boolean; view: string };
counts: {
all: number;
inbox: number;
preparing: number;
ready: number;
favorites: number;
completed: number;
archived: number;
deleted: number;
topics: Record<string, number>;
};
topics: { id: string; name: string; iconName: string }[];
} = $props();
let collapsed = $state(false);
let searchInput = $state<HTMLInputElement>();
onMount(() => {
collapsed = localStorage.getItem('sendewerk-pieces-navigation') === 'collapsed';
});
function toggle() {
collapsed = !collapsed;
localStorage.setItem('sendewerk-pieces-navigation', collapsed ? 'collapsed' : 'open');
}
async function revealSearch() {
if (collapsed) toggle();
await tick();
searchInput?.focus();
}
const fundusLinks = $derived([
{
href: '/fundus',
label: 'Alle Pieces',
icon: 'library',
count: counts.all,
active: !filters.status && !filters.favorites && !filters.view
},
{ href: '/eingang', label: 'Eingang', icon: 'inbox', count: counts.inbox, active: false },
{
href: '/fundus?view=completed',
label: 'Abgeschlossen',
icon: 'circle-check',
count: counts.completed,
active: filters.view === 'completed'
},
{
href: '/fundus?status=preparing',
label: 'In Vorbereitung',
icon: 'pen',
count: counts.preparing,
active: filters.status === 'preparing'
},
{
href: '/fundus?status=ready',
label: 'Sendefertig',
icon: 'circle-check',
count: counts.ready,
active: filters.status === 'ready'
},
{
href: '/fundus?favorites=1',
label: 'Favoriten',
icon: 'star',
count: counts.favorites,
active: filters.favorites
},
{
href: '/archiv',
label: 'Archiv',
icon: 'box-archive',
count: counts.archived,
active: false
},
{
href: '/archiv#deleted-pieces-title',
label: 'Papierkorb',
icon: 'trash',
count: counts.deleted,
active: false
}
]);
</script>
<aside class="fundus-navigation" class:collapsed aria-label="Pieces-Navigation">
<header>
<div>
<p class="eyebrow">Bibliothek</p>
<h1>Pieces</h1>
</div>
<button
type="button"
onclick={toggle}
aria-label={collapsed ? 'Pieces-Navigation ausklappen' : 'Pieces-Navigation einklappen'}
aria-expanded={!collapsed}
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
>
</header>
{#if collapsed}
<button
class="collapsed-search-button"
type="button"
onclick={revealSearch}
aria-label="Pieces durchsuchen"><AppIcon name="magnifying-glass" /></button
>
{:else}
<form class="context-search" method="GET" action="/fundus" role="search">
{#if filters.status}<input type="hidden" name="status" value={filters.status} />{/if}
{#if filters.topicId}<input type="hidden" name="topic" value={filters.topicId} />{/if}
{#if filters.favorites}<input type="hidden" name="favorites" value="1" />{/if}
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
<label
><AppIcon name="magnifying-glass" /><span class="sr-only">Pieces durchsuchen</span><input
bind:this={searchInput}
name="q"
type="search"
value={filters.query}
placeholder="Pieces durchsuchen"
/></label
>
</form>
{/if}
<nav>
{#each fundusLinks as item (item.href)}
<a
href={item.href}
class:active={item.active}
aria-current={item.active ? 'page' : undefined}
>
<AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong>
</a>
{/each}
</nav>
{#if topics.length}
<section>
<h2>Themen</h2>
<nav aria-label="Themenfilter">
{#each topics as topic (topic.id)}
<a
href={`/fundus?topic=${encodeURIComponent(topic.id)}`}
class:active={filters.topicId === topic.id}
>
<AppIcon name={topic.iconName || 'tag'} /><span>{topic.name}</span><strong
>{counts.topics[topic.id] ?? 0}</strong
>
</a>
{/each}
</nav>
</section>
{/if}
</aside>
+16 -2
View File
@@ -6,6 +6,7 @@
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let searchInput: HTMLInputElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
@@ -14,18 +15,31 @@
)
);
let visible = $derived(filtered.slice(0, 120));
function openAndFocus(event: MouseEvent) {
if (menu.open) return;
event.preventDefault();
menu.open = true;
searchInput.focus({ preventScroll: true });
}
</script>
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<details
bind:this={menu}
ontoggle={() => {
if (menu.open) queueMicrotask(() => searchInput.focus());
}}
>
<summary onclick={openAndFocus}><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
><span class="sr-only">Icons durchsuchen</span><input
type="search"
placeholder="Icon suchen"
bind:value={query}
bind:this={searchInput}
/></label
>
<div class="icon-picker-grid">
@@ -1,85 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
name,
label,
value = $bindable(),
rows = 4,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let textarea: HTMLTextAreaElement;
async function replaceSelection(before: string, after = before, fallback = 'Text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || fallback;
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
async function addList() {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || 'Stichpunkt';
const replacement = selected
.split('\n')
.map((line) => `- ${line}`)
.join('\n');
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + 2, start + replacement.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
</script>
<div class:private-field={privateField} class="markdown-editor-field">
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
><strong>B</strong></button
>
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button
>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
><AppIcon name="list-check" /> Liste</button
>
<button
type="button"
aria-label="Link"
title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
><AppIcon name="link" /> Link</button
>
</span>
</div>
<textarea
bind:this={textarea}
aria-label={label}
{name}
{rows}
{placeholder}
{form}
maxlength="50000"
bind:value></textarea>
</div>
+35 -15
View File
@@ -6,6 +6,8 @@
labelledby,
activeSection = $bindable(),
onrequestclose,
inline = false,
sidePanel = false,
header,
children,
footer
@@ -13,12 +15,14 @@
labelledby: string;
activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void;
inline?: boolean;
sidePanel?: boolean;
header: Snippet;
children: Snippet;
footer: Snippet;
} = $props();
let dialog: HTMLDialogElement;
let dialog = $state<HTMLDialogElement>();
let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 };
@@ -30,24 +34,18 @@
}
export function showModal() {
if (!dialog.open) dialog.showModal();
if (!inline && dialog && !dialog.open) {
if (sidePanel && window.matchMedia('(min-width: 52.01rem)').matches) dialog.show();
else dialog.showModal();
}
}
export function close() {
dialog.close();
if (!inline) dialog?.close();
}
</script>
<dialog
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
{#snippet editorChrome()}
<header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div>
<button
@@ -57,12 +55,13 @@
onclick={onrequestclose}><AppIcon name="xmark" /></button
>
</header>
{#if !inline}
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button
type="button"
class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button
onclick={() => selectSection('script')}>Streamtext</button
>
<button
type="button"
@@ -77,6 +76,27 @@
onclick={() => selectSection('presentation')}>Präsentation</button
>
</nav>
{/if}
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer>
</dialog>
{/snippet}
{#if inline}
<section class="piece-editor-dialog fundus-editor-surface" aria-labelledby={labelledby}>
{@render editorChrome()}
</section>
{:else}
<dialog
bind:this={dialog}
class="piece-editor-dialog"
class:piece-editor-side-panel={sidePanel}
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
{@render editorChrome()}
</dialog>
{/if}
@@ -62,7 +62,6 @@
</article>
{:else if content.mode === 'image'}
<article class="output-content output-image-only">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
{#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article>
{:else if content.mode === 'link'}
+143
View File
@@ -0,0 +1,143 @@
<script lang="ts">
import { Editor } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
name,
label,
value = $bindable(),
rows = 4,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let element: HTMLDivElement;
let valueInput: HTMLInputElement;
let editor = $state<Editor>();
let sourceMode = $state(false);
let sourceWarning = $state(false);
let transaction = $state(0);
function announceChange(markdown: string) {
value = markdown;
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
}
function command(run: (chain: ReturnType<Editor['chain']>) => boolean) {
if (!editor) return;
run(editor.chain().focus());
}
function active(name: string, attributes?: Record<string, unknown>) {
return transaction >= 0 && (editor?.isActive(name, attributes) ?? false);
}
function showRichText() {
if (!editor) return;
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
sourceMode = false;
}
function editLink() {
if (!editor) return;
const previous = String(editor.getAttributes('link').href ?? '');
const href = window.prompt('Linkadresse', previous || 'https://');
if (href === null) return;
if (!href.trim()) editor.chain().focus().extendMarkRange('link').unsetLink().run();
else editor.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
}
onMount(() => {
editor = new Editor({
element,
extensions: [StarterKit, Markdown],
content: value,
contentType: 'markdown',
editorProps: {
attributes: {
class: 'rich-markdown-content',
'aria-label': label,
'data-placeholder': placeholder
}
},
onUpdate: ({ editor: current }) => announceChange(current.getMarkdown()),
onTransaction: () => (transaction += 1)
});
const roundTrip = editor.getMarkdown().trimEnd();
if (value.trimEnd() !== roundTrip) {
sourceWarning = true;
sourceMode = true;
}
return () => editor?.destroy();
});
</script>
<div class:private-field={privateField} class="markdown-editor-field rich-markdown-field">
<input bind:this={valueInput} type="hidden" {name} {form} {value} />
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
{#if !sourceMode}
<button
class:active={active('bold')}
type="button"
aria-label="Fett"
onclick={() => command((chain) => chain.toggleBold().run())}><strong>B</strong></button
>
<button
class:active={active('italic')}
type="button"
aria-label="Kursiv"
onclick={() => command((chain) => chain.toggleItalic().run())}><em>I</em></button
>
<button
class:active={active('bulletList')}
type="button"
aria-label="Liste"
onclick={() => command((chain) => chain.toggleBulletList().run())}
><AppIcon name="list-check" /></button
>
<button
class:active={active('heading', { level: 2 })}
type="button"
aria-label="Zwischenüberschrift"
onclick={() => command((chain) => chain.toggleHeading({ level: 2 }).run())}>H2</button
>
<button class:active={active('link')} type="button" aria-label="Link" onclick={editLink}
><AppIcon name="link" /></button
>
{/if}
<button
type="button"
class:active={sourceMode}
onclick={() => (sourceMode ? showRichText() : (sourceMode = true))}
>{sourceMode ? 'Visuell' : 'Markdown'}</button
>
</span>
</div>
{#if sourceWarning}<p class="markdown-source-warning">
Dieser Text enthält Markdown, das visuell nicht verlustfrei abgebildet werden kann. Die
Quellansicht bleibt deshalb aktiv.
</p>{/if}
<textarea
hidden={!sourceMode}
aria-label={`${label}, Markdown-Quelltext`}
rows={Math.max(rows, 6)}
maxlength="50000"
bind:value></textarea>
<div hidden={sourceMode} class="rich-markdown-surface" bind:this={element}></div>
</div>
@@ -0,0 +1,219 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
};
let {
entries,
categories,
form = null
}: {
entries: FeedEntry[];
categories: { id: string; name: string }[];
form?: { intent?: string; feedError?: string } | null;
} = $props();
let dialog: HTMLDialogElement;
let closeButton: HTMLButtonElement;
let selectedIds = $state<string[]>([]);
let selectedFeedIds = $state<string[]>([]);
let categoryIds = $state<string[]>([]);
let page = $state(1);
const pageSize = 20;
let feeds = $derived(
Array.from(new Map(entries.map((entry) => [entry.feedId, entry.feedName])).entries()).map(
([id, name]) => ({ id, name })
)
);
let filteredEntries = $derived(entries.filter((entry) => selectedFeedIds.includes(entry.feedId)));
let pageCount = $derived(Math.max(1, Math.ceil(filteredEntries.length / pageSize)));
let visibleEntries = $derived(filteredEntries.slice((page - 1) * pageSize, page * pageSize));
let allSourcesSelected = $derived(feeds.length > 0 && selectedFeedIds.length === feeds.length);
onMount(() => {
selectedFeedIds = feeds.map((feed) => feed.id);
if (form?.intent === 'feedImport' && form.feedError) void showModal();
});
export async function showModal() {
if (!selectedFeedIds.length) selectedFeedIds = feeds.map((feed) => feed.id);
if (!dialog.open) dialog.showModal();
await tick();
closeButton.focus();
}
function close() {
dialog.close();
}
function selectAllSources() {
selectedFeedIds = feeds.map((feed) => feed.id);
page = 1;
}
function toggleSource(id: string) {
selectedFeedIds = selectedFeedIds.includes(id)
? selectedFeedIds.filter((feedId) => feedId !== id)
: [...selectedFeedIds, id];
page = 1;
}
function toggleEntry(id: string) {
selectedIds = selectedIds.includes(id)
? selectedIds.filter((entryId) => entryId !== id)
: [...selectedIds, id];
}
function handleEntryKeydown(event: KeyboardEvent, id: string) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleEntry(id);
}
</script>
<dialog
bind:this={dialog}
class="management-dialog rss-selection-dialog"
aria-labelledby="rss-selection-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2 id="rss-selection-title">RSS-Beiträge auswählen</h2>
</div>
</div>
<button
bind:this={closeButton}
class="dialog-close"
type="button"
aria-label="RSS-Auswahl schließen"
onclick={close}><AppIcon name="xmark" /></button
>
</header>
<form class="rss-selection-form" method="POST" action="?/importFeeds">
{#each selectedIds as id (id)}<input type="hidden" name="entryIds" value={id} />{/each}
<div class="rss-selection-content">
{#if form?.intent === 'feedImport' && form.feedError}<div
class="form-message error"
role="alert"
>
{form.feedError}
</div>{/if}
<nav class="rss-source-filters" aria-label="RSS-Quellen filtern">
<button
type="button"
class:active={allSourcesSelected}
aria-pressed={allSourcesSelected}
onclick={selectAllSources}>Alle Quellen</button
>
{#each feeds as feed (feed.id)}
<button
type="button"
class:active={selectedFeedIds.includes(feed.id)}
aria-pressed={selectedFeedIds.includes(feed.id)}
onclick={() => toggleSource(feed.id)}>{feed.name}</button
>
{/each}
</nav>
<div class="rss-result-summary" aria-live="polite">
<strong>{filteredEntries.length} verfügbare Beiträge</strong>
<span>Seite {page} von {pageCount}</span>
</div>
<div
class="rss-entry-list"
role="listbox"
aria-label="Verfügbare RSS-Beiträge"
aria-multiselectable="true"
>
{#each visibleEntries as entry (entry.id)}
<div
class:selected={selectedIds.includes(entry.id)}
class="rss-entry"
role="option"
tabindex="0"
aria-selected={selectedIds.includes(entry.id)}
onclick={() => toggleEntry(entry.id)}
onkeydown={(event) => handleEntryKeydown(event, entry.id)}
>
<div class="rss-entry-state" aria-hidden="true">
<AppIcon name={selectedIds.includes(entry.id) ? 'check' : 'plus'} />
</div>
<div class="rss-entry-copy">
<small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
>
<strong>{entry.title}</strong>
{#if entry.summary}<p>{entry.summary}</p>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}>Original öffnen</a
>
</div>
{#if selectedIds.includes(entry.id)}<span class="rss-selected-label">Ausgewählt</span
>{/if}
</div>
{:else}<p class="empty-copy">
Für diese Quellenauswahl sind keine Beiträge verfügbar.
</p>{/each}
</div>
{#if pageCount > 1}<nav class="rss-pagination" aria-label="RSS-Beitragsseiten">
<button
class="secondary-button"
type="button"
disabled={page === 1}
onclick={() => (page -= 1)}>Zurück</button
>
<span
>{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filteredEntries.length)} von {filteredEntries.length}</span
>
<button
class="secondary-button"
type="button"
disabled={page === pageCount}
onclick={() => (page += 1)}>Weiter</button
>
</nav>{/if}
<div class="rss-category-selection">
<span class="field-label">Kategorien für die Auswahl</span>
<small>Ohne Auswahl gilt die Standardkategorie der jeweiligen Quelle.</small>
<ChoicePicker
items={categories}
selectedIds={categoryIds}
label="Kategorien"
fieldName="categoryIds"
onselectionchange={(ids) => (categoryIds = ids)}
/>
</div>
</div>
<footer class="rss-selection-actions">
<button
class="text-button danger-text"
type="submit"
formaction="?/discardFeeds"
disabled={!selectedIds.length}>Auswahl verwerfen</button
>
<button class="primary-button" type="submit" disabled={!selectedIds.length}>
Auswahl in den Eingang übernehmen ({selectedIds.length})
</button>
</footer>
</form>
</dialog>
+86 -16
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type ShareShow = {
@@ -26,12 +27,19 @@
shows,
shares,
fixedShowId = '',
catalogPanel = false,
form = null
}: {
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
catalogPanel?: boolean;
form?: {
intent?: string;
error?: string;
success?: string | boolean;
shareUrl?: string;
} | null;
} = $props();
let visibleShows = $derived(
@@ -40,6 +48,17 @@
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
let dialog: HTMLDialogElement;
let copyMessage = $state('');
onMount(() => {
if (form?.intent?.toLowerCase().includes('share')) dialog.showModal();
});
async function copyShareUrl() {
if (!form?.shareUrl) return;
await navigator.clipboard.writeText(new URL(form.shareUrl, window.location.origin).href);
copyMessage = 'Link kopiert.';
}
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
@@ -87,26 +106,54 @@
}
</script>
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
{#if catalogPanel}
<section class="quiet-panel share-catalog-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2>Lesefreigaben</h2>
</div>
<button
class="compact-add-button share-dialog-trigger"
type="button"
aria-label="Lesefreigaben"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Freigaben verwalten
</button>
</div>
<p class="catalog-note">
Streams gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
</p>
</section>
{:else}
<button
class="secondary-button share-dialog-trigger"
type="button"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Lesefreigaben
</button>
</button>
{/if}
<dialog bind:this={dialog} class="share-management-dialog">
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
>
<section class="share-management" aria-labelledby={`share-title-${fixedShowId || 'all'}`}>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
<button
class="dialog-close"
type="button"
aria-label="Lesefreigaben schließen"
onclick={() => dialog.close()}
>
<AppIcon name="xmark" />
</button>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
Der Gast sieht nur diesen Stream und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
@@ -124,8 +171,15 @@
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<div>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a>
<a href={form.shareUrl}>{form.shareUrl}</a><button
class="secondary-button"
type="button"
onclick={copyShareUrl}><AppIcon name="copy" /> Kopieren</button
>
</div>
{#if copyMessage}<small>{copyMessage}</small>{/if}
</div>{/if}
<form class="share-create-form" method="POST" action="?/createShare">
@@ -133,8 +187,8 @@
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
>Stream<select name="showId" required>
<option value="">Stream wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
@@ -158,7 +212,8 @@
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
>
</form>
{#if visibleShows.length}
@@ -229,6 +284,20 @@
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
{#if !share.revokedAtUtc}<form
method="POST"
action="?/replaceShare"
onsubmit={(event) => {
if (
!confirm('Ersatzlink anlegen? Der bisherige Link wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button" type="submit">Ersatzlink anlegen</button>
</form>{/if}
<form
method="POST"
action="?/deleteShare"
@@ -241,13 +310,14 @@
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
<button class="text-button danger-text" type="submit">Freigabe löschen</button
>
</form>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
<p class="quiet-copy">Für diesen Stream gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
@@ -256,5 +326,5 @@
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</section>
</dialog>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- filter links are fixed local routes assembled below */
import { onMount, tick } from 'svelte';
import { resolve } from '$app/paths';
import AppIcon from './AppIcon.svelte';
let {
query,
state: filterState,
counts,
shows
}: {
query: string;
state: string;
counts: Record<string, number>;
shows: { id: string; title: string; startsAtUtc: string | null }[];
} = $props();
let collapsed = $state(false);
let searchInput = $state<HTMLInputElement>();
const links = $derived([
{
href: '/sendungen',
label: 'Alle Streams',
icon: 'calendar-days',
count: counts.all,
active: !filterState
},
{
href: '/sendungen?state=created',
label: 'Angelegt',
icon: 'file',
count: counts.created,
active: filterState === 'created'
},
{
href: '/sendungen?state=preparing',
label: 'In Vorbereitung',
icon: 'pen',
count: counts.preparing,
active: filterState === 'preparing'
},
{
href: '/sendungen?state=ready',
label: 'Sendefertig',
icon: 'circle-check',
count: counts.ready,
active: filterState === 'ready'
},
{
href: '/sendungen?state=completed',
label: 'Abgeschlossen',
icon: 'box-archive',
count: counts.completed,
active: filterState === 'completed'
}
]);
onMount(() => {
collapsed = localStorage.getItem('sendewerk-stream-navigation') === 'collapsed';
});
function toggle() {
collapsed = !collapsed;
localStorage.setItem('sendewerk-stream-navigation', collapsed ? 'collapsed' : 'open');
}
async function revealSearch() {
if (collapsed) toggle();
await tick();
searchInput?.focus();
}
</script>
<aside class="fundus-navigation stream-navigation" class:collapsed aria-label="Streams-Navigation">
<header>
<div>
<p class="eyebrow">Planung</p>
<h1>Streams</h1>
</div>
<button
type="button"
onclick={toggle}
aria-label={collapsed ? 'Streams-Navigation ausklappen' : 'Streams-Navigation einklappen'}
aria-expanded={!collapsed}
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
>
</header>
{#if collapsed}
<button
class="collapsed-search-button"
type="button"
onclick={revealSearch}
aria-label="Streams durchsuchen"><AppIcon name="magnifying-glass" /></button
>
{:else}
<form class="context-search" method="GET" action={resolve('/sendungen')} role="search">
<label
><AppIcon name="magnifying-glass" /><span class="sr-only">Streams durchsuchen</span><input
bind:this={searchInput}
name="q"
type="search"
value={query}
placeholder="Streams durchsuchen"
/></label
>
</form>
{/if}
<nav>
{#each links as item (item.href)}
<a href={item.href} class:active={item.active} aria-current={item.active ? 'page' : undefined}
><AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong></a
>
{/each}
</nav>
{#if shows.length && !collapsed}
<section>
<h2>Geplant</h2>
<nav aria-label="Geplante Streams">
{#each shows as show (show.id)}
<a href={resolve('/sendungen/[id]', { id: show.id })}
><AppIcon name="calendar" /><span>{show.title}</span></a
>
{/each}
</nav>
</section>
{/if}
</aside>
@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
type TechnicalAccess = {
id: string;
name: string;
permissions: 'read' | 'read_write';
};
let {
form = null
}: {
form?: {
intent?: string;
error?: string;
success?: string | boolean;
technicalToken?: string;
} | null;
} = $props();
let dialog: HTMLDialogElement;
let nameInput = $state<HTMLInputElement>();
let copyMessage = $state('');
let access = $state<TechnicalAccess | null>(null);
let name = $state('');
let permissions = $state<'read' | 'read_write'>('read');
onMount(() => {
if (form?.intent === 'technicalAccess' && (form.error || form.technicalToken)) void showModal();
});
export async function showModal(selectedAccess: TechnicalAccess | null = null) {
access = selectedAccess;
name = selectedAccess?.name ?? '';
permissions = selectedAccess?.permissions ?? 'read';
if (!dialog.open) dialog.showModal();
await tick();
(form?.technicalToken && !access
? dialog.querySelector<HTMLButtonElement>('.token-copy')
: nameInput
)?.focus();
}
function close() {
dialog.close();
}
async function copyToken() {
if (!form?.technicalToken) return;
try {
await navigator.clipboard.writeText(form.technicalToken);
copyMessage = 'Token kopiert.';
} catch {
copyMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
</script>
<dialog
bind:this={dialog}
class="management-dialog technical-access-dialog"
aria-labelledby="technical-access-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="key" /></span>
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2 id="technical-access-dialog-title">{access?.name || 'API-Zugang hinzufügen'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Zugangsdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
{#if form?.technicalToken && !access}
<div class="technical-token-result">
<div class="form-message success" role="status">
<strong>Token, einmalig sichtbar</strong>
<p>Jetzt kopieren und sicher hinterlegen. Sendewerk speichert weiterhin nur den Hash.</p>
</div>
<code>{form.technicalToken}</code>
<button class="primary-button token-copy" type="button" onclick={copyToken}
><AppIcon name="copy" /> Token kopieren</button
>
{#if copyMessage}<small role="status">{copyMessage}</small>{/if}
</div>
{:else}
<form
class="management-dialog-form"
method="POST"
action={access ? '?/updateTechnicalAccess' : '?/createTechnicalAccess'}
>
{#if access}<input type="hidden" name="accessId" value={access.id} />{/if}
{#if form?.intent === 'technicalAccess' && form.error}<div
class="form-message error dialog-form-message"
role="alert"
>
{form.error}
</div>{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select bind:value={permissions} name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{access ? 'Änderungen speichern' : 'Zugang anlegen'}</button
>
</footer>
</form>
{/if}
</dialog>
+148 -70
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import { onMount, untrack } from 'svelte';
import RichMarkdownField from './RichMarkdownField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import PresentationDisplay from './PresentationDisplay.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
@@ -13,7 +14,8 @@
sectionId,
sections,
error,
enhanceCommand
enhanceCommand,
open = false
}: {
usage: PieceUsage;
revision: number;
@@ -21,6 +23,7 @@
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
open?: boolean;
} = $props();
let formId = $derived(`usage-editor-form-${usage.id}`);
@@ -30,6 +33,14 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
let selectedSourceId = $state(
untrack(
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
)
);
onMount(() => {
if (open) editor.showModal();
});
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
@@ -38,12 +49,63 @@
return Number((seconds / 60).toFixed(2));
}
function previewHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('\n', '<br>');
}
function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false;
editor.close();
}
function presentationSourceChanged(event: Event) {
selectedSourceId = (event.currentTarget as HTMLSelectElement).value;
const source = draft.sources.find((item) => item.id === selectedSourceId);
if (!source) return;
draft.presentationSourceLabel = source.label;
draft.presentationSourceUrl = source.url;
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
}
let previewContent = $derived({
mode: draft.presentationMode,
topics: draft.topics.map((topic) => topic.name),
title: draft.outputTitle || draft.title,
html: previewHtml(draft.presentation),
source: draft.presentationSourceUrl
? {
label: draft.presentationSourceLabel,
url: draft.presentationSourceUrl,
timecodeSeconds: draft.presentationSourceTimecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationMode === 'link' ? draft.presentationImageUrl : '',
siteName: draft.presentationSiteName,
provider: draft.presentationProvider,
providerId: draft.presentationProviderId,
embedUrl: '',
sources: [],
images: selectedMedia
? [{ id: selectedMedia.originalMediaId, altText: selectedMedia.altText }]
: []
});
let previewImageSrc = $derived(
selectedMedia && draft.presentationMode === 'text'
? '/media'
: selectedMedia && draft.presentationMode === 'image'
? `/media/${selectedMedia.originalMediaId}`
: ''
);
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
@@ -68,9 +130,10 @@
bind:activeSection
labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose}
sidePanel
>
{#snippet header()}
<p class="eyebrow">Sendungsfassung</p>
<p class="eyebrow">Streamfassung</p>
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet}
@@ -91,31 +154,31 @@
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3>
<h3>Streamtext</h3>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
<RichMarkdownField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro}
/>
<MarkdownEditorField
<RichMarkdownField
name="moderation"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
<RichMarkdownField
name="interaction"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interaction}
/>
<MarkdownEditorField
<RichMarkdownField
name="transition"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
@@ -125,6 +188,26 @@
<details class="piece-meta-details">
<summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid">
<label
>Vorbereitungsstatus<select
name="preparationStatus"
bind:value={draft.preparationStatus}
>
<option value="preparing">In Vorbereitung</option>
<option value="ready">Sendefertig</option>
</select></label
>
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
>
<option value={null}>Nicht angegeben</option>
<option value="low">Gering</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
@@ -148,7 +231,7 @@
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
<RichMarkdownField
name="research"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -156,10 +239,10 @@
privateField
bind:value={draft.research}
/>
<MarkdownEditorField
<RichMarkdownField
name="ownNotes"
label="Interne Notizen zu dieser Verwendung"
placeholder="Absprachen und Hinweise nur für diese Sendung"
placeholder="Absprachen und Hinweise nur für diesen Stream"
privateField
bind:value={draft.ownNotes}
/>
@@ -178,7 +261,7 @@
<p class="eyebrow">Snapshot</p>
<h3>Werkzeuge</h3>
</div>
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
<small>Name und Start-URL bleiben Teil der Streamfassung.</small>
</div>
{#if draft.tools.length}<div class="source-list">
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
@@ -198,7 +281,7 @@
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
>
</div>{/each}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Streamfassung.</p>{/if}
</section>
</section>
@@ -212,20 +295,7 @@
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<p class="field-label">Präsentationsart</p>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
@@ -238,6 +308,25 @@
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<RichMarkdownField
name="presentation"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentation" value={draft.presentation} />
{/if}
<input
type="hidden"
name="presentationSourceLabel"
@@ -253,32 +342,42 @@
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
<label class="wide-field"
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
bind:value={selectedSourceId}
onchange={presentationSourceChanged}
>
<option value="">Quelle auswählen</option>
{#each draft.sources as source (source.id)}
<option value={source.id}>{source.label || source.url}</option>
{/each}
</select></label
>
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Kartentitel<input
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
{#if draft.presentationMode === 'link'}
<label
>Bild-URL<input
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
@@ -298,9 +397,11 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
{#if draft.media.length}<fieldset class="usage-media-picker">
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
class="usage-media-picker"
>
<legend>Hochgeladenes Präsentationsbild</legend>
<label
{#if draft.presentationMode === 'text'}<label
><input
type="radio"
checked={!draft.presentationMediaId}
@@ -309,7 +410,7 @@
dirty = true;
}}
/> Kein Bild</label
>
>{/if}
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
@@ -326,30 +427,7 @@
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{/if}
<PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
</aside>
</div>
</section>
@@ -361,7 +439,7 @@
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>Aus Piece aktualisieren</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
@@ -393,7 +471,7 @@
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
@@ -401,7 +479,7 @@
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>Aus Stream entfernen</button
>
</form>
</div>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark';
const preferences: { value: Preference; label: string; icon: string }[] = [
{ value: 'light', label: 'Hell', icon: 'sun' },
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'display' }
];
let { displayName, role }: { displayName: string; role: 'barkeeper' | 'thekencrew' } = $props();
let preference = $state<Preference>('system');
function apply(next: Preference) {
preference = next;
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = next === 'system' ? (dark ? 'dark' : 'light') : next;
document.documentElement.dataset.themePreference = next;
localStorage.setItem('sendewerk-theme', next);
}
onMount(() => {
const stored = localStorage.getItem('sendewerk-theme');
preference = preferences.some((item) => item.value === stored)
? (stored as Preference)
: 'system';
const media = window.matchMedia('(prefers-color-scheme: dark)');
const followSystem = () => preference === 'system' && apply('system');
media.addEventListener('change', followSystem);
return () => media.removeEventListener('change', followSystem);
});
</script>
<details class="account-menu user-menu">
<summary aria-label={`Benutzermenü für ${displayName}`}>
<span aria-hidden="true">{displayName.slice(0, 1).toUpperCase()}</span>
</summary>
<div class="user-menu-popover">
<header>
<strong>{displayName}</strong>
<small>{role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</header>
{#if role === 'barkeeper'}
<a class="user-menu-link" href={resolve('/verwaltung')}>
<AppIcon name="gear" /><span>Einstellungen & Verwaltung</span>
</a>
{/if}
<fieldset>
<legend>Erscheinungsbild</legend>
<div class="theme-menu-options">
{#each preferences as item (item.value)}
<button
type="button"
class:active={preference === item.value}
aria-pressed={preference === item.value}
onclick={() => apply(item.value)}
>
<AppIcon name={item.icon} />
{item.label}
</button>
{/each}
</div>
</fieldset>
<form method="POST" action="/logout">
<button class="user-menu-link" type="submit"
><AppIcon name="right-from-bracket" /> Abmelden</button
>
</form>
</div>
</details>
+47
View File
@@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
export async function apiBody(request: Request) {
const type = request.headers.get('content-type') ?? '';
if (!type.includes('application/json')) throw new Error('API_JSON_REQUIRED');
try {
return (await request.json()) as Record<string, unknown>;
} catch {
throw new Error('API_INVALID_JSON');
}
}
export function apiFailure(cause: unknown) {
const code = cause instanceof Error ? cause.message : 'API_ERROR';
const status =
code === 'API_UNAUTHORIZED'
? 401
: code === 'API_FORBIDDEN'
? 403
: code === 'NOT_FOUND'
? 404
: code === 'CONFLICT' || code === 'API_IDEMPOTENCY_CONFLICT'
? 409
: 400;
return json(
{
error: {
code,
message:
status === 401
? 'Authentifizierung erforderlich.'
: status === 403
? 'Diese Berechtigung fehlt.'
: status === 404
? 'Nicht gefunden.'
: 'Anfrage konnte nicht verarbeitet werden.'
}
},
{ status }
);
}
export function pageInput(url: URL) {
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0);
return { limit, offset };
}
+15 -1
View File
@@ -564,7 +564,13 @@ export async function resetPassword(token: string, password: string): Promise<vo
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
)
.run(timestamp, row.user_id);
database.prepare('DELETE FROM oauth_authorization_codes WHERE user_id = ?').run(row.user_id);
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
database
.prepare(
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
)
.run(timestamp, row.user_id);
});
reset();
}
@@ -592,7 +598,15 @@ export function setUserLocked(actor: AuthUser, userId: string, locked: boolean):
database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.run(locked ? nowUtc() : null, nowUtc(), userId);
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
if (locked) {
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
database
.prepare(
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
)
.run(nowUtc(), userId);
database.prepare('DELETE FROM oauth_authorization_codes WHERE user_id = ?').run(userId);
}
});
update();
}
+4
View File
@@ -18,6 +18,10 @@ export function getAuthConfig() {
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
}
const origin = new URL(configuredOrigin || 'http://localhost:5173');
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname);
if (process.env.NODE_ENV === 'production' && origin.protocol !== 'https:' && !loopback) {
throw new Error('SENDEWERK_APP_ORIGIN muss öffentlich HTTPS verwenden.');
}
return {
origin,
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 17,
migrations: 20,
pieces: 6,
shows: 2,
usages: 5,
+134
View File
@@ -0,0 +1,134 @@
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, vi } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
discardFeedEntries,
fetchDueFeeds,
fetchFeed,
importFeedEntries,
listFeedEntries,
saveFeedSource
} from './feeds.js';
import { getPiece } from './content.js';
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }])
}));
const validFeed = `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title><item>
<guid>entry-1</guid><title>Ein Beitrag</title><link>https://example.org/post</link>
<description><![CDATA[<p>Kurz <strong>und</strong> gut.</p><script>evil()</script>]]></description>
<pubDate>Sat, 24 Oct 2026 12:00:00 GMT</pubDate></item></channel></rss>`;
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-feed-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(() => {
vi.unstubAllGlobals();
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('RSS import inbox', () => {
it('keeps one entry across fetches and preserves a discard decision', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () => new Response(validFeed, { headers: { 'content-type': 'application/rss+xml' } })
)
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
expect(entry.title).toBe('Ein Beitrag');
expect(discardFeedEntries([entry.id])).toBe(1);
await fetchFeed(feedId);
expect(listFeedEntries()).toHaveLength(0);
expect(listFeedEntries('discarded')).toHaveLength(1);
});
it('sanitizes foreign HTML and retains the original source on explicit import', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(validFeed))
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: 'cat-jc'
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
const [pieceId] = importFeedEntries([entry.id], []);
const piece = getPiece(pieceId)!;
expect(piece.summary).toContain('Kurz und gut.');
expect(piece.summary).not.toContain('<script>');
expect(piece.sources).toEqual([
{ label: 'RSS: Originalbeitrag', url: 'https://example.org/post', timecodeSeconds: null }
]);
expect(piece.categoryIds).toEqual(['cat-jc']);
});
it('isolates a broken feed while fetching other due feeds', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async (url: URL) => new Response(url.href.includes('broken') ? '<not-feed />' : validFeed)
)
);
saveFeedSource(null, {
name: 'A broken',
url: 'https://example.org/broken.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
saveFeedSource(null, {
name: 'B valid',
url: 'https://example.org/valid.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchDueFeeds();
expect(listFeedEntries()).toHaveLength(1);
const errors = getDatabase()
.prepare("SELECT count(*) AS count FROM feed_sources WHERE last_error <> ''")
.get() as { count: number };
expect(errors.count).toBe(1);
});
it('blocks loopback targets before sending a request', async () => {
const request = vi.fn();
vi.stubGlobal('fetch', request);
const feedId = saveFeedSource(null, {
name: 'Local',
url: 'http://127.0.0.1/feed.xml',
isEnabled: false,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await expect(fetchFeed(feedId)).rejects.toThrow('FEED_BLOCKED');
expect(request).not.toHaveBeenCalled();
});
});
+457
View File
@@ -0,0 +1,457 @@
import { createHash, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import sanitizeHtml from 'sanitize-html';
import { XMLParser } from 'fast-xml-parser';
import { createPiece, getPiece, savePiece } from './content.js';
import { getDatabase } from './database.js';
const MAX_FEED_BYTES = 2 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8_000;
const MAX_REDIRECTS = 3;
export type FeedSource = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
lastFetchedAtUtc: string | null;
nextFetchAtUtc: string | null;
lastError: string;
};
export type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
status: 'new' | 'imported' | 'discarded';
pieceId: string | null;
defaultCategoryId: string | null;
};
function clean(value: unknown, maximum: number) {
return String(value ?? '')
.trim()
.slice(0, maximum);
}
function validHttpUrl(value: string) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
} catch {
return false;
}
}
function privateIpv4(address: string) {
const p = address.split('.').map(Number);
return (
p[0] === 0 ||
p[0] === 10 ||
p[0] === 127 ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && p[1] === 168) ||
p[0] >= 224
);
}
function privateAddress(address: string) {
if (isIP(address) === 4) return privateIpv4(address);
const value = address.toLowerCase().split('%')[0];
if (value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd'))
return true;
if (/^fe[89ab]/.test(value)) return true;
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? privateIpv4(mapped) : false;
}
async function assertPublic(url: URL) {
if (!validHttpUrl(url.href) || (url.port && !['80', '443'].includes(url.port)))
throw new Error('FEED_BLOCKED');
const hostname = url.hostname.replace(/^\[|\]$/g, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) throw new Error('FEED_BLOCKED');
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some((entry) => privateAddress(entry.address)))
throw new Error('FEED_BLOCKED');
}
async function fetchXml(
input: string,
headers: Record<string, string>,
redirects = 0
): Promise<{ xml: string; etag: string; lastModified: string; unchanged: boolean }> {
const url = new URL(input);
await assertPublic(url);
const response = await fetch(url, {
headers: {
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
...headers
},
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirects >= MAX_REDIRECTS) throw new Error('FEED_REDIRECT');
const location = response.headers.get('location');
if (!location) throw new Error('FEED_FETCH');
return fetchXml(new URL(location, url).href, headers, redirects + 1);
}
if (response.status === 304)
return {
xml: '',
etag: headers['If-None-Match'] ?? '',
lastModified: headers['If-Modified-Since'] ?? '',
unchanged: true
};
if (!response.ok) throw new Error(`FEED_HTTP_${response.status}`);
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
return {
xml: buffer.toString('utf8'),
etag: response.headers.get('etag') ?? '',
lastModified: response.headers.get('last-modified') ?? '',
unchanged: false
};
}
function values<T>(value: T | T[] | undefined): T[] {
return value === undefined ? [] : Array.isArray(value) ? value : [value];
}
function textFrom(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return textFrom(record['#text'] ?? record.__cdata ?? '');
}
return '';
}
function entryLink(value: unknown) {
for (const link of values(
value as Record<string, unknown> | Record<string, unknown>[] | string | undefined
)) {
if (typeof link === 'string') return link;
if (link && typeof link === 'object') {
const href = clean((link as Record<string, unknown>)['@_href'], 2_000);
const rel = clean((link as Record<string, unknown>)['@_rel'], 30);
if (href && (!rel || rel === 'alternate')) return href;
}
}
return '';
}
function parseDate(value: unknown) {
const raw = textFrom(value);
if (!raw) return null;
const date = new Date(raw);
return Number.isNaN(date.valueOf()) ? null : date.toISOString();
}
function parseFeed(xml: string) {
let document: Record<string, unknown>;
try {
document = new XMLParser({
ignoreAttributes: false,
processEntities: false,
trimValues: true
}).parse(xml) as Record<string, unknown>;
} catch {
throw new Error('INVALID_FEED');
}
const rssChannel = (document.rss as Record<string, unknown> | undefined)?.channel as
Record<string, unknown> | undefined;
const atom = document.feed as Record<string, unknown> | undefined;
if (!rssChannel && !atom) throw new Error('INVALID_FEED');
const rawEntries = values(
(rssChannel?.item ?? atom?.entry) as
Record<string, unknown> | Record<string, unknown>[] | undefined
);
return rawEntries.slice(0, 200).flatMap((entry) => {
const title = clean(textFrom(entry.title), 300);
const url = clean(entryLink(entry.link) || textFrom(entry.link), 2_000);
if (!title || !validHttpUrl(url)) return [];
const guid = clean(textFrom(entry.guid ?? entry.id), 2_000);
const externalId = guid || createHash('sha256').update(url).digest('hex');
const rawSummary = textFrom(entry.description ?? entry.summary ?? entry.content);
const summary = clean(
sanitizeHtml(rawSummary, { allowedTags: [], allowedAttributes: {} }),
5_000
);
return [
{
externalId,
title,
url,
summary,
publishedAtUtc: parseDate(entry.pubDate ?? entry.published ?? entry.updated)
}
];
});
}
export function listFeedSources(): FeedSource[] {
return (
getDatabase()
.prepare(
`SELECT id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, last_fetched_at_utc, next_fetch_at_utc, last_error FROM feed_sources ORDER BY name`
)
.all() as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
name: String(row.name),
url: String(row.url),
isEnabled: row.is_enabled === 1,
refreshIntervalMinutes: Number(row.refresh_interval_minutes),
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null,
lastFetchedAtUtc: row.last_fetched_at_utc ? String(row.last_fetched_at_utc) : null,
nextFetchAtUtc: row.next_fetch_at_utc ? String(row.next_fetch_at_utc) : null,
lastError: String(row.last_error)
}));
}
export function saveFeedSource(
id: string | null,
input: {
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
}
) {
const name = clean(input.name, 120);
const url = clean(input.url, 2_000);
if (
!name ||
!validHttpUrl(url) ||
!Number.isInteger(input.refreshIntervalMinutes) ||
input.refreshIntervalMinutes < 15 ||
input.refreshIntervalMinutes > 10080
)
throw new Error('INVALID_FEED');
const database = getDatabase();
if (
input.defaultCategoryId &&
!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(input.defaultCategoryId)
)
throw new Error('INVALID_TAXONOMY');
const now = new Date().toISOString();
if (!id) {
id = randomUUID();
database
.prepare(
`INSERT INTO feed_sources (id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, next_fetch_at_utc, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? now : null,
now,
now
);
} else {
const result = database
.prepare(
`UPDATE feed_sources SET name = ?, url = ?, is_enabled = ?, refresh_interval_minutes = ?,
default_category_id = ?, next_fetch_at_utc = CASE WHEN ? = 1 THEN COALESCE(next_fetch_at_utc, ?) ELSE NULL END,
updated_at_utc = ? WHERE id = ?`
)
.run(
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? 1 : 0,
now,
now,
id
);
if (!result.changes) throw new Error('NOT_FOUND');
}
return id;
}
export function deleteFeedSource(id: string) {
if (!getDatabase().prepare('DELETE FROM feed_sources WHERE id = ?').run(id).changes)
throw new Error('NOT_FOUND');
}
export function listFeedEntries(status: 'new' | 'imported' | 'discarded' = 'new'): FeedEntry[] {
return (
getDatabase()
.prepare(
`SELECT e.id, e.feed_id, f.name AS feed_name, e.title, e.url, e.summary,
e.published_at_utc, e.status, e.piece_id, f.default_category_id FROM feed_entries e
JOIN feed_sources f ON f.id = e.feed_id WHERE e.status = ? ORDER BY COALESCE(e.published_at_utc, e.first_seen_at_utc) DESC`
)
.all(status) as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
feedId: String(row.feed_id),
feedName: String(row.feed_name),
title: String(row.title),
url: String(row.url),
summary: String(row.summary),
publishedAtUtc: row.published_at_utc ? String(row.published_at_utc) : null,
status: row.status as FeedEntry['status'],
pieceId: row.piece_id ? String(row.piece_id) : null,
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null
}));
}
export async function fetchFeed(feedId: string) {
const database = getDatabase();
const feed = database.prepare('SELECT * FROM feed_sources WHERE id = ?').get(feedId) as
Record<string, unknown> | undefined;
if (!feed) throw new Error('NOT_FOUND');
const now = new Date();
try {
const response = await fetchXml(String(feed.url), {
...(feed.etag ? { 'If-None-Match': String(feed.etag) } : {}),
...(feed.last_modified ? { 'If-Modified-Since': String(feed.last_modified) } : {})
});
let inserted = 0;
if (!response.unchanged) {
const add =
database.prepare(`INSERT INTO feed_entries (id, feed_id, external_id, title, url, summary,
published_at_utc, first_seen_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(feed_id, external_id) DO UPDATE SET title = excluded.title, url = excluded.url,
summary = excluded.summary, published_at_utc = excluded.published_at_utc, updated_at_utc = excluded.updated_at_utc`);
for (const entry of parseFeed(response.xml)) {
const existed = database
.prepare('SELECT 1 FROM feed_entries WHERE feed_id = ? AND external_id = ?')
.get(feedId, entry.externalId);
add.run(
randomUUID(),
feedId,
entry.externalId,
entry.title,
entry.url,
entry.summary,
entry.publishedAtUtc,
now.toISOString(),
now.toISOString()
);
if (!existed) inserted += 1;
}
}
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
`UPDATE feed_sources SET etag = ?, last_modified = ?, last_fetched_at_utc = ?,
next_fetch_at_utc = ?, last_error = '', updated_at_utc = ? WHERE id = ?`
)
.run(
response.etag,
response.lastModified,
now.toISOString(),
next,
now.toISOString(),
feedId
);
return { inserted, unchanged: response.unchanged };
} catch (cause) {
const message = cause instanceof Error ? cause.message.slice(0, 500) : 'FEED_FETCH';
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
'UPDATE feed_sources SET last_fetched_at_utc = ?, next_fetch_at_utc = ?, last_error = ?, updated_at_utc = ? WHERE id = ?'
)
.run(now.toISOString(), next, message, now.toISOString(), feedId);
throw cause;
}
}
export async function fetchDueFeeds() {
const rows = getDatabase()
.prepare(
`SELECT id FROM feed_sources WHERE is_enabled = 1
AND (next_fetch_at_utc IS NULL OR next_fetch_at_utc <= ?) ORDER BY next_fetch_at_utc LIMIT 10`
)
.all(new Date().toISOString()) as { id: string }[];
for (const row of rows) await fetchFeed(row.id).catch(() => undefined);
}
export function importFeedEntries(entryIds: string[], categoryIds: string[]) {
const database = getDatabase();
const categories = [...new Set(categoryIds.filter(Boolean))];
if (categories.length > 20) throw new Error('INVALID_TAXONOMY');
for (const category of categories)
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(category))
throw new Error('INVALID_TAXONOMY');
return database.transaction(() =>
entryIds.map((entryId) => {
const entry = database
.prepare(
`SELECT e.*, f.default_category_id FROM feed_entries e JOIN feed_sources f ON f.id = e.feed_id
WHERE e.id = ? AND e.status = 'new'`
)
.get(entryId) as Record<string, unknown> | undefined;
if (!entry) throw new Error('NOT_FOUND');
const pieceId = createPiece('blank');
const piece = getPiece(pieceId)!;
const assigned = categories.length
? categories
: entry.default_category_id
? [String(entry.default_category_id)]
: [];
savePiece(pieceId, piece.revision, {
...piece,
title: String(entry.title),
summary: String(entry.summary),
status: 'inbox',
categoryIds: assigned,
sources: [{ label: 'RSS: Originalbeitrag', url: String(entry.url), timecodeSeconds: null }]
});
database
.prepare(
"UPDATE feed_entries SET status = 'imported', piece_id = ?, updated_at_utc = ? WHERE id = ?"
)
.run(pieceId, new Date().toISOString(), entryId);
return pieceId;
})
)();
}
export function discardFeedEntries(entryIds: string[]) {
const database = getDatabase();
const update = database.prepare(
"UPDATE feed_entries SET status = 'discarded', updated_at_utc = ? WHERE id = ? AND status = 'new'"
);
const now = new Date().toISOString();
return database.transaction(() =>
entryIds.reduce((count, id) => count + update.run(now, id).changes, 0)
)();
}
let schedulerStarted = false;
export function startFeedScheduler() {
if (schedulerStarted || process.env.NODE_ENV === 'test') return;
schedulerStarted = true;
void fetchDueFeeds();
setInterval(() => void fetchDueFeeds(), 60_000).unref();
}
+83
View File
@@ -0,0 +1,83 @@
import { getTaxonomy, listPieces } from './content.js';
import { getPieceUsageContexts } from './shows.js';
export function fundusSearch(url: URL) {
const search = new URLSearchParams(url.searchParams);
for (const key of [...search.keys()]) if (key.startsWith('/')) search.delete(key);
const value = search.toString();
return value ? `?${value}` : '';
}
export function loadFundusWorkspace(url: URL) {
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1',
favorites: url.searchParams.get('favorites') === '1',
view: url.searchParams.get('view') ?? ''
};
const statuses =
filters.status === 'ready'
? (['ready'] as const)
: filters.status === 'preparing'
? (['preparing'] as const)
: (['preparing', 'ready'] as const);
let pieces = listPieces({
statuses: [...statuses],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
pieces = pieces.filter((piece) =>
filters.view === 'completed'
? usageContexts[piece.id]?.sentHistory.length > 0
: usageContexts[piece.id]?.sentHistory.length === 0
);
if (filters.favorites) pieces = pieces.filter((piece) => piece.isFavorite);
const taxonomy = getTaxonomy();
const allPieces = listPieces({ statuses: ['inbox', 'preparing', 'ready', 'archived'] });
const activePieces = allPieces.filter(
(piece) => piece.status === 'preparing' || piece.status === 'ready'
);
const allUsageContexts = getPieceUsageContexts(activePieces.map((piece) => piece.id));
const availablePieces = activePieces.filter(
(piece) => allUsageContexts[piece.id]?.sentHistory.length === 0
);
const deletedPieces = listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'],
deleted: true
});
return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy,
filters,
counts: {
all: availablePieces.length,
inbox: allPieces.filter((piece) => piece.status === 'inbox').length,
preparing: allPieces.filter((piece) => piece.status === 'preparing').length,
ready: allPieces.filter((piece) => piece.status === 'ready').length,
favorites: availablePieces.filter((piece) => piece.isFavorite).length,
completed: activePieces.filter((piece) => allUsageContexts[piece.id]?.sentHistory.length > 0)
.length,
archived: allPieces.filter((piece) => piece.status === 'archived').length,
deleted: deletedPieces.length,
topics: Object.fromEntries(
taxonomy.topics.map((topic) => [
topic.id,
allPieces.filter(
(piece) =>
(piece.status === 'preparing' || piece.status === 'ready') &&
piece.topics.some((item) => item.id === topic.id)
).length
])
)
}
};
}
+51
View File
@@ -0,0 +1,51 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import { searchWorkspace } from './global-search.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-search-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('global workspace search', () => {
it('finds private piece fields and keeps archived pieces discoverable', () => {
const privateMatch = searchWorkspace('Herstellerangaben');
expect(privateMatch.pieces).toEqual([
expect.objectContaining({ id: 'piece-console', matchLabel: 'Recherche' })
]);
getDatabase().prepare("UPDATE pieces SET status = 'archived' WHERE id = 'piece-console'").run();
const archivedMatch = searchWorkspace('Herstellerangaben');
expect(archivedMatch.pieces).toEqual([
expect.objectContaining({
id: 'piece-console',
status: 'archived',
href: '/archiv#piece-piece-console'
})
]);
});
it('requires at least two characters and groups stream snapshot matches separately', () => {
expect(searchWorkspace('x')).toEqual({ pieces: [], shows: [] });
const results = searchWorkspace('kleine Konsole');
expect(results.pieces.some((result) => result.id === 'piece-console')).toBe(true);
expect(results.shows.length).toBeGreaterThan(0);
expect(results.shows[0].href).toMatch(/^\/sendungen\//);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { getDatabase } from './database.js';
import type { PieceStatus } from './content.js';
import type { ShowStatus } from './shows.js';
export type SearchResult = {
id: string;
title: string;
href: string;
status: PieceStatus | ShowStatus;
matchLabel: string;
excerpt: string;
};
export type GlobalSearchResults = {
pieces: SearchResult[];
shows: SearchResult[];
};
type SearchField = { label: string; value: string };
function normalized(value: unknown): string {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function match(
fields: SearchField[],
query: string
): Pick<SearchResult, 'matchLabel' | 'excerpt'> | null {
for (const field of fields) {
const value = normalized(field.value);
const position = value.toLocaleLowerCase('de-DE').indexOf(query);
if (position === -1) continue;
const start = Math.max(0, position - 70);
const end = Math.min(value.length, position + query.length + 100);
return {
matchLabel: field.label,
excerpt: `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`
};
}
return null;
}
export function searchWorkspace(rawQuery: string): GlobalSearchResults {
const query = normalized(rawQuery).toLocaleLowerCase('de-DE').slice(0, 200);
if (query.length < 2) return { pieces: [], shows: [] };
const database = getDatabase();
const pieces = (
database
.prepare(
`SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
group_concat(COALESCE(ps.label, '') || ' ' || COALESCE(ps.url, ''), char(10)) AS sources
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
WHERE p.deleted_at_utc IS NULL
GROUP BY p.id ORDER BY p.updated_at_utc DESC`
)
.all() as Record<string, unknown>[]
)
.map((row): SearchResult | null => {
const found = match(
[
{ label: 'Titel', value: String(row.title) },
{ label: 'Ausgabetitel', value: String(row.output_title) },
{ label: 'Zusammenfassung', value: String(row.summary) },
{ label: 'Anmoderation', value: String(row.intro) },
{ label: 'Recherche', value: String(row.research_text) },
{ label: 'Moderation', value: String(row.moderation_notes) },
{ label: 'Interaktion', value: String(row.interaction_text) },
{ label: 'Übergang', value: String(row.transition_text) },
{ label: 'Präsentation', value: String(row.presentation_text) },
{ label: 'Quellen', value: String(row.sources) }
],
query
);
if (!found) return null;
const status = row.status as PieceStatus;
return {
id: String(row.id),
title: String(row.title),
href: status === 'archived' ? `/archiv#piece-${row.id}` : `/fundus/${row.id}`,
status,
...found
};
})
.filter((result): result is SearchResult => result !== null)
.slice(0, 50);
const shows = (
database
.prepare(
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text,
group_concat(COALESCE(s.name, '') || ' ' || COALESCE(u.title_snapshot, '') || ' ' ||
COALESCE(u.output_title_snapshot, '') || ' ' || COALESCE(u.intro_snapshot, '') || ' ' ||
COALESCE(u.research_snapshot, '') || ' ' || COALESCE(u.moderation_snapshot, '') || ' ' ||
COALESCE(u.interaction_snapshot, '') || ' ' || COALESCE(u.transition_snapshot, '') || ' ' ||
COALESCE(u.presentation_snapshot, '') || ' ' || COALESCE(u.own_notes, ''), char(10)) AS contents
FROM shows sh
LEFT JOIN show_sections s ON s.show_id = sh.id
LEFT JOIN piece_usages u ON u.show_section_id = s.id
GROUP BY sh.id ORDER BY sh.starts_at_utc DESC, sh.created_at_utc DESC`
)
.all() as Record<string, unknown>[]
)
.map((row): SearchResult | null => {
const found = match(
[
{ label: 'Titel', value: String(row.title) },
{ label: 'Twitch-Titel', value: String(row.twitch_title) },
{ label: 'Ankündigung', value: String(row.announcement_text) },
{ label: 'Ablauf und Pieces', value: String(row.contents) }
],
query
);
if (!found) return null;
return {
id: String(row.id),
title: String(row.title),
href: `/sendungen/${row.id}`,
status: row.status as ShowStatus,
...found
};
})
.filter((result): result is SearchResult => result !== null)
.slice(0, 50);
return { pieces, shows };
}
+87 -2
View File
@@ -7,8 +7,9 @@ import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
clearPresentationChannel,
getPresentationChannelView,
getLiveView,
getPresentationChannelStatus,
getPresentationChannelView,
getPresentationMedia,
getPresentationView,
goBack,
@@ -16,6 +17,7 @@ import {
moveOpenUsage,
presentationChannelAccess,
presentUsage,
resumeUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent,
@@ -59,6 +61,11 @@ describe('live mode', () => {
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
)
.run();
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 600,
elapsedSeconds: 100
});
completeCurrent(
'show-october',
'timer-next-piece',
@@ -104,15 +111,58 @@ describe('live mode', () => {
.find((usage) => usage.id === 'usage-console')?.status
).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
expect(() =>
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision)
).toThrow('INVALID_STATUS');
resumeUsage('show-october', 'usage-console', 'resume-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console',
status: 'current'
});
});
it('resumes a completed piece and its earlier section timer without resetting total time', () => {
const database = getDatabase();
database
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
)
.run();
database
.prepare("UPDATE show_sections SET explicit_duration_seconds = 200 WHERE id = 'section-game'")
.run();
database
.prepare(
"UPDATE piece_usages SET show_section_id = 'section-game', position = 0 WHERE id = 'usage-effects'"
)
.run();
const start = new Date('2026-10-23T18:00:00.000Z');
startOrResumeLive('show-october', 'resume-start', 0, start);
completeCurrent('show-october', 'resume-next', 1, new Date('2026-10-23T18:01:00.000Z'));
let live = getLiveView('show-october', new Date('2026-10-23T18:01:30.000Z'));
expect(live.current?.id).toBe('usage-effects');
resumeUsage(
'show-october',
'usage-console',
'resume-earlier',
live.liveRevision,
new Date('2026-10-23T18:01:30.000Z')
);
live = getLiveView('show-october', new Date('2026-10-23T18:02:00.000Z'));
expect(live.current).toMatchObject({ id: 'usage-console', status: 'current' });
expect(live.completedCount).toBe(0);
expect(live.sectionTimer).toMatchObject({
sectionId: 'section-stories',
plannedDurationSeconds: 300,
elapsedSeconds: 90
});
expect(live.totalTimer.elapsedSeconds).toBe(120);
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
const key = issuePresentationAccess('show-october');
expect(getPresentationChannelStatus('show-october')).toBe('connected');
expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({
@@ -128,6 +178,7 @@ describe('live mode', () => {
presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel();
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull();
@@ -239,6 +290,40 @@ describe('live mode', () => {
expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
getDatabase()
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('presentation-snapshot', 'usage-console', 'presentation-image',
'Konsole auf dem Tisch', 0, 1)`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'text',
presentation_image_only_snapshot = 1,
presentation_image_url_snapshot = 'https://private.example.test/not-for-image-mode'
WHERE id = 'usage-console'`
)
.run();
presentUsage(
'show-october',
'usage-console',
'present-image-only',
getLiveView('show-october').liveRevision
);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'image',
imageUrl: '',
images: [{ id: 'presentation-snapshot', altText: 'Konsole auf dem Tisch' }],
sources: []
});
expect(await getPresentationMedia(token, 'presentation-snapshot')).toMatchObject({
mimeType: 'image/png',
byteSize: 10,
altText: 'Konsole auf dem Tisch'
});
const replacementToken = issuePresentationAccess('show-october');
expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe(
+81 -10
View File
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database
.prepare(
`SELECT s.is_reserve_bank FROM piece_usages u
`SELECT s.is_reserve_bank, u.status FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
.get(usageId, showId) as { is_reserve_bank: number; status: string } | undefined;
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
return row;
}
function closeActiveRun(database: Database.Database, showId: string, now: string) {
@@ -265,8 +266,7 @@ function syncSectionTimer(
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
)
.get(showId) as
| { id: string; section_id: string; planned_duration_seconds: number }
| undefined;
{ id: string; section_id: string; planned_duration_seconds: number } | undefined;
if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now);
@@ -279,6 +279,29 @@ function syncSectionTimer(
}
if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const previous = database
.prepare(
`SELECT id FROM live_section_timers WHERE show_id = ? AND section_id = ?
ORDER BY started_at_utc DESC LIMIT 1`
)
.get(showId, section.id) as { id: string } | undefined;
if (previous) {
database
.prepare(
`UPDATE live_section_timers SET planned_duration_seconds = ?, ended_at_utc = NULL
WHERE id = ?`
)
.run(section.explicit_duration_seconds, previous.id);
if (showTimerRunning(database, showId)) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), previous.id, now);
}
return;
}
const timerId = randomUUID();
database
.prepare(
@@ -287,12 +310,14 @@ function syncSectionTimer(
VALUES (?, ?, ?, ?, ?)`
)
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
if (showTimerRunning(database, showId)) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timerId, now);
}
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
@@ -412,6 +437,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
const state = ensureLiveState(database, showId, now);
const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND');
if (state.started_at_utc && !state.ended_at_utc && state.current_usage_id) {
syncSectionTimer(database, showId, state.current_usage_id, now);
}
const durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({
...section,
@@ -941,7 +969,8 @@ export function selectUsage(
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
usageBelongsToShow(database, showId, usageId);
const target = usageBelongsToShow(database, showId, usageId);
if (target.status !== 'open') throw new Error('INVALID_STATUS');
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
@@ -955,6 +984,36 @@ export function selectUsage(
);
}
export function resumeUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume-usage',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
const target = usageBelongsToShow(database, showId, usageId);
if (target.status !== 'done' && target.status !== 'skipped')
throw new Error('INVALID_STATUS');
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
@@ -1099,6 +1158,14 @@ export function presentationChannelAccess(channelId: string, accessKey: string)
);
}
export function getPresentationChannelStatus(showId: string) {
const channel = getDatabase()
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
.get() as { show_id: string | null } | undefined;
if (!channel?.show_id) return 'disconnected' as const;
return channel.show_id === showId ? ('connected' as const) : ('other_show' as const);
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const channel = database
@@ -1165,10 +1232,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const mode = usage.presentation_mode_snapshot;
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
mode,
topics: (
database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
@@ -1185,7 +1253,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
: null,
previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot,
imageUrl: mode === 'link' ? usage.presentation_image_url_snapshot : '',
siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot,
@@ -1195,7 +1263,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot
),
sources: [],
images: (
images: ['text', 'image'].includes(mode)
? (
database
.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
@@ -1203,13 +1272,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
)
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text }))
: []
}
};
}
export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null;
if (!view?.content?.imageUrl || view.content.mode !== 'link') return null;
try {
return await fetchPresentationImage(view.content.imageUrl);
} catch {
@@ -1233,7 +1303,8 @@ export async function getPresentationMedia(
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1
AND (u.presentation_image_only_snapshot = 1 OR u.presentation_mode_snapshot = 'text')`
)
.get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
+433
View File
@@ -0,0 +1,433 @@
import type { AuthUser } from './auth.js';
import { PIECE_STATUSES, getPiece, listPieces, type PieceStatus } from './content.js';
import { SHOW_STATUSES, getShow, listShows, type ShowStatus } from './shows.js';
import { MCP_SCOPE } from './oauth.js';
const MAX_PAGE_SIZE = 100;
type JsonRpcId = string | number | null;
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: JsonRpcId;
method: string;
params?: Record<string, unknown>;
};
const oauthSecurity = [{ type: 'oauth2', scopes: [MCP_SCOPE] }];
const readOnlyAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
};
const profileOutputSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
id: { type: 'string', minLength: 1, pattern: '\\S' },
name: { type: 'string' },
email: { type: 'string' },
nickname: { type: 'string' }
},
required: ['id'],
additionalProperties: false
};
const paginationSchema = {
type: 'object',
properties: {
limit: { type: 'integer' },
offset: { type: 'integer' },
total: { type: 'integer' }
},
required: ['limit', 'offset', 'total'],
additionalProperties: false
};
const tools = [
{
name: 'get_profile',
title: 'Sendewerk-Profil',
description: 'Gibt das mit dieser Verbindung verknüpfte Sendewerk-Konto zurück.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
outputSchema: profileOutputSchema,
annotations: readOnlyAnnotations,
securitySchemes: oauthSecurity,
_meta: { 'openai/profile': true, securitySchemes: oauthSecurity }
},
{
name: 'list_pieces',
title: 'Pieces durchsuchen',
description: 'Listet und durchsucht redaktionelle Pieces in Sendewerk.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', maxLength: 200 },
statuses: {
type: 'array',
items: { type: 'string', enum: PIECE_STATUSES },
uniqueItems: true
},
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 }
},
additionalProperties: false
},
outputSchema: {
type: 'object',
properties: {
pieces: { type: 'array', items: { type: 'object' } },
pagination: paginationSchema
},
required: ['pieces', 'pagination'],
additionalProperties: false
},
annotations: readOnlyAnnotations,
securitySchemes: oauthSecurity,
_meta: { securitySchemes: oauthSecurity }
},
{
name: 'get_piece',
title: 'Piece lesen',
description: 'Liest die redaktionellen Texte und Quellen eines einzelnen Pieces.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
required: ['id'],
additionalProperties: false
},
outputSchema: {
type: 'object',
properties: { id: { type: 'string' }, title: { type: 'string' } },
required: ['id', 'title']
},
annotations: readOnlyAnnotations,
securitySchemes: oauthSecurity,
_meta: { securitySchemes: oauthSecurity }
},
{
name: 'list_shows',
title: 'Streams auflisten',
description: 'Listet Streams mit Status, Termin, Dauer und Abschnittszahl.',
inputSchema: {
type: 'object',
properties: {
statuses: {
type: 'array',
items: { type: 'string', enum: SHOW_STATUSES },
uniqueItems: true
},
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 }
},
additionalProperties: false
},
outputSchema: {
type: 'object',
properties: {
shows: { type: 'array', items: { type: 'object' } },
pagination: paginationSchema
},
required: ['shows', 'pagination'],
additionalProperties: false
},
annotations: readOnlyAnnotations,
securitySchemes: oauthSecurity,
_meta: { securitySchemes: oauthSecurity }
},
{
name: 'get_show_rundown',
title: 'Streamablauf lesen',
description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve eines Streams.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
required: ['id'],
additionalProperties: false
},
outputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
sections: { type: 'array', items: { type: 'object' } },
reserve: { type: 'array', items: { type: 'object' } }
},
required: ['id', 'title', 'sections', 'reserve']
},
annotations: readOnlyAnnotations,
securitySchemes: oauthSecurity,
_meta: { securitySchemes: oauthSecurity }
}
];
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function integer(
value: unknown,
fallback: number,
minimum: number,
maximum = Number.MAX_SAFE_INTEGER
) {
if (value === undefined) return fallback;
if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) {
throw new Error('INVALID_ARGUMENTS');
}
return Number(value);
}
function stringArgument(value: unknown, maximum: number, required = false): string {
if (value === undefined && !required) return '';
if (typeof value !== 'string' || (required && !value) || value.length > maximum) {
throw new Error('INVALID_ARGUMENTS');
}
return value;
}
function enumArray<T extends string>(value: unknown, allowed: readonly T[], fallback: T[]): T[] {
if (value === undefined) return fallback;
if (!Array.isArray(value) || value.some((item) => !allowed.includes(item as T))) {
throw new Error('INVALID_ARGUMENTS');
}
return [...new Set(value as T[])];
}
function exactKeys(input: Record<string, unknown>, allowed: string[]): void {
if (Object.keys(input).some((key) => !allowed.includes(key)))
throw new Error('INVALID_ARGUMENTS');
}
function toolResult(value: unknown) {
return {
content: [{ type: 'text', text: JSON.stringify(value) }],
structuredContent: value,
isError: false
};
}
function toolError(message: string) {
return { content: [{ type: 'text', text: message }], isError: true };
}
function projectPieceUsage(
usage: NonNullable<ReturnType<typeof getShow>>['sections'][number]['usages'][number]
) {
return {
id: usage.id,
originalPieceId: usage.originalPieceId,
position: usage.position,
status: usage.status,
preparationStatus: usage.preparationStatus,
preparationEffort: usage.preparationEffort,
durationSeconds: usage.durationSeconds,
title: usage.title,
outputTitle: usage.outputTitle,
intro: usage.intro,
research: usage.research,
moderation: usage.moderation,
interaction: usage.interaction,
transition: usage.transition,
presentation: usage.presentation,
ownNotes: usage.ownNotes,
sources: usage.sources.map(({ label, url, timecodeSeconds }) => ({
label,
url,
timecodeSeconds
})),
topics: usage.topics.map(({ name }) => name)
};
}
function callTool(name: string, input: unknown, user: AuthUser) {
if (!isObject(input)) throw new Error('INVALID_ARGUMENTS');
switch (name) {
case 'get_profile': {
exactKeys(input, []);
const profile = { id: user.id, name: user.displayName };
return {
content: [{ type: 'text', text: JSON.stringify(profile) }],
structuredContent: profile,
isError: false
};
}
case 'list_pieces': {
exactKeys(input, ['query', 'statuses', 'limit', 'offset']);
const statuses = enumArray<PieceStatus>(input.statuses, PIECE_STATUSES, [
'inbox',
'preparing',
'ready'
]);
const query = stringArgument(input.query, 200);
const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE);
const offset = integer(input.offset, 0, 0);
const all = listPieces({ statuses, query });
return toolResult({
pieces: all.slice(offset, offset + limit).map(({ mediaId, formatId, ...piece }) => {
void mediaId;
void formatId;
return piece;
}),
pagination: { limit, offset, total: all.length }
});
}
case 'get_piece': {
exactKeys(input, ['id']);
const id = stringArgument(input.id, 100, true);
const piece = getPiece(id);
if (!piece) return toolError('Piece nicht gefunden.');
const listEntry = listPieces({ statuses: [...PIECE_STATUSES] }).find(
(entry) => entry.id === id
);
return toolResult({
id: piece.id,
title: piece.title,
outputTitle: piece.outputTitle,
summary: piece.summary,
status: piece.status,
preparationEffort: piece.preparationEffort,
durationSeconds: piece.durationSeconds,
format: listEntry?.format ?? '',
categories: listEntry?.categories.map(({ name }) => name) ?? [],
topics: listEntry?.topics.map(({ name }) => name) ?? [],
intro: piece.intro,
research: piece.researchText,
moderation: piece.moderationNotes,
interaction: piece.interactionText,
transition: piece.transitionText,
presentation: piece.presentationText,
sources: piece.sources,
createdAtUtc: piece.createdAtUtc,
updatedAtUtc: piece.updatedAtUtc
});
}
case 'list_shows': {
exactKeys(input, ['statuses', 'limit', 'offset']);
const statuses = enumArray<ShowStatus>(input.statuses, SHOW_STATUSES, [...SHOW_STATUSES]);
const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE);
const offset = integer(input.offset, 0, 0);
const all = listShows(statuses);
return toolResult({
shows: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
}
case 'get_show_rundown': {
exactKeys(input, ['id']);
const show = getShow(stringArgument(input.id, 100, true));
if (!show) return toolError('Stream nicht gefunden.');
return toolResult({
id: show.id,
title: show.title,
startsAtUtc: show.startsAtUtc,
twitchTitle: show.twitchTitle,
announcementText: show.announcementText,
status: show.status,
totalDurationSeconds: show.totalDurationSeconds,
sections: show.sections.map((section) => ({
id: section.id,
name: section.name,
category: section.category,
position: section.position,
explicitDurationSeconds: section.explicitDurationSeconds,
durationSeconds: section.durationSeconds,
twitchTags: section.twitchTags,
pieces: section.usages.map(projectPieceUsage)
})),
reserve: show.reserve.map(projectPieceUsage),
createdAtUtc: show.createdAtUtc,
updatedAtUtc: show.updatedAtUtc,
completedAtUtc: show.completedAtUtc
});
}
default:
throw new Error('UNKNOWN_TOOL');
}
}
export function validJsonRpcRequest(value: unknown): value is JsonRpcRequest {
if (!isObject(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string') return false;
if (
'id' in value &&
value.id !== null &&
typeof value.id !== 'string' &&
typeof value.id !== 'number'
) {
return false;
}
return value.params === undefined || isObject(value.params);
}
export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
const id = request.id ?? null;
try {
switch (request.method) {
case 'server/discover':
return {
jsonrpc: '2.0',
id,
result: {
resultType: 'complete',
supportedVersions: ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26'],
capabilities: { tools: { listChanged: false } },
_meta: {
'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' }
},
instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.',
ttlMs: 3_600_000,
cacheScope: 'private'
}
};
case 'initialize': {
const requestedVersion = request.params?.protocolVersion;
const supported = ['2025-11-25', '2025-06-18', '2025-03-26'];
const protocolVersion =
typeof requestedVersion === 'string' && supported.includes(requestedVersion)
? requestedVersion
: supported[0];
return {
jsonrpc: '2.0',
id,
result: {
protocolVersion,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'Sendewerk', version: '0.7.3' },
instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.'
}
};
}
case 'ping':
return { jsonrpc: '2.0', id, result: { resultType: 'complete' } };
case 'tools/list':
return { jsonrpc: '2.0', id, result: { resultType: 'complete', tools } };
case 'tools/call': {
const name = request.params?.name;
if (typeof name !== 'string') throw new Error('INVALID_ARGUMENTS');
return {
jsonrpc: '2.0',
id,
result: {
resultType: 'complete',
...callTool(name, request.params?.arguments ?? {}, user)
}
};
}
default:
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
}
} catch (cause) {
const message = cause instanceof Error ? cause.message : '';
return {
jsonrpc: '2.0',
id,
error:
message === 'INVALID_ARGUMENTS'
? { code: -32602, message: 'Invalid params' }
: message === 'UNKNOWN_TOOL'
? { code: -32602, message: 'Unknown tool' }
: { code: -32603, message: 'Internal error' }
};
}
}
+427
View File
@@ -0,0 +1,427 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { AuthUser } from './auth.js';
import { getAuthConfig } from './config.js';
import { getDatabase } from './database.js';
export const MCP_SCOPE = 'sendewerk:read';
const AUTHORIZATION_CODE_DURATION_MS = 5 * 60 * 1000;
const ACCESS_TOKEN_DURATION_SECONDS = 60 * 60;
const MAX_REGISTERED_CLIENTS = 1000;
const MCP_REQUEST_LIMIT_PER_HOUR = 600;
type OAuthClientRow = {
id: string;
name: string;
redirect_uris_json: string;
};
type AuthorizationCodeRow = {
client_id: string;
user_id: string;
redirect_uri: string;
scope: string;
resource: string;
code_challenge: string;
expires_at_utc: string;
consumed_at_utc: string | null;
};
export type OAuthClient = {
id: string;
name: string;
redirectUris: string[];
};
export type OAuthAuthorizationRequest = {
client: OAuthClient;
redirectUri: string;
state: string;
scope: typeof MCP_SCOPE;
resource: string;
codeChallenge: string;
};
export class OAuthError extends Error {
constructor(
public code: string,
message: string,
public status = 400
) {
super(message);
}
}
function nowUtc(): string {
return new Date().toISOString();
}
function futureUtc(durationMs: number): string {
return new Date(Date.now() + durationMs).toISOString();
}
function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function randomToken(): string {
return randomBytes(32).toString('base64url');
}
export function oauthIssuer(): string {
return getAuthConfig().origin.origin;
}
export function mcpResource(): string {
return new URL('/mcp', getAuthConfig().origin).toString();
}
export function protectedResourceMetadataUrl(): string {
return new URL('/.well-known/oauth-protected-resource/mcp', getAuthConfig().origin).toString();
}
function validRedirectUri(value: string): boolean {
try {
const url = new URL(value);
if (url.hash || url.username || url.password) return false;
if (url.protocol === 'https:') return true;
return (
url.protocol === 'http:' &&
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
);
} catch {
return false;
}
}
function clientFromRow(row: OAuthClientRow): OAuthClient {
return { id: row.id, name: row.name, redirectUris: JSON.parse(row.redirect_uris_json) };
}
export function registerOAuthClient(input: unknown): OAuthClient {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new OAuthError('invalid_client_metadata', 'Client-Metadaten fehlen.');
}
const metadata = input as Record<string, unknown>;
const name = typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '';
const redirectUris = Array.isArray(metadata.redirect_uris)
? [...new Set(metadata.redirect_uris.filter((uri): uri is string => typeof uri === 'string'))]
: [];
if (
!name ||
name.length > 120 ||
// OAuth client names must not contain control or bidi-control characters.
// eslint-disable-next-line no-control-regex
/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name) ||
!redirectUris.length ||
redirectUris.length > 10
) {
throw new OAuthError('invalid_client_metadata', 'Name und Redirect-URIs sind erforderlich.');
}
if (redirectUris.some((uri) => uri.length > 1000 || !validRedirectUri(uri))) {
throw new OAuthError(
'invalid_redirect_uri',
'Redirect-URIs müssen HTTPS oder lokale HTTP-Adressen sein.'
);
}
if (
metadata.token_endpoint_auth_method !== undefined &&
metadata.token_endpoint_auth_method !== 'none'
) {
throw new OAuthError(
'invalid_client_metadata',
'Nur öffentliche PKCE-Clients werden unterstützt.'
);
}
if (
(Array.isArray(metadata.grant_types) && !metadata.grant_types.includes('authorization_code')) ||
(Array.isArray(metadata.response_types) && !metadata.response_types.includes('code'))
) {
throw new OAuthError('invalid_client_metadata', 'Nur Authorization Code wird unterstützt.');
}
const client: OAuthClient = { id: randomToken(), name, redirectUris };
const database = getDatabase();
const timestamp = nowUtc();
database
.prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?')
.run(timestamp);
database
.prepare(
'DELETE FROM oauth_access_tokens WHERE expires_at_utc <= ? OR revoked_at_utc IS NOT NULL'
)
.run(timestamp);
database
.prepare(
`DELETE FROM oauth_clients WHERE created_at_utc < ?
AND NOT EXISTS (SELECT 1 FROM oauth_authorization_codes c WHERE c.client_id = oauth_clients.id)
AND NOT EXISTS (SELECT 1 FROM oauth_access_tokens t WHERE t.client_id = oauth_clients.id)`
)
.run(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
const count = database.prepare('SELECT count(*) AS count FROM oauth_clients').get() as {
count: number;
};
if (count.count >= MAX_REGISTERED_CLIENTS) {
throw new OAuthError(
'temporarily_unavailable',
'Derzeit sind keine weiteren Registrierungen möglich.',
503
);
}
database
.prepare(
'INSERT INTO oauth_clients (id, name, redirect_uris_json, created_at_utc) VALUES (?, ?, ?, ?)'
)
.run(client.id, client.name, JSON.stringify(client.redirectUris), timestamp);
return client;
}
export function reserveOAuthAttempt(kind: 'registration' | 'token', address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const limit = kind === 'registration' ? 20 : 120;
const addressHash = tokenHash(address || 'unknown');
return database.transaction(() => {
database.prepare('DELETE FROM oauth_rate_limit_attempts WHERE attempted_at_utc < ?').run(since);
const row = database
.prepare(
`SELECT count(*) AS count FROM oauth_rate_limit_attempts
WHERE kind = ? AND address_hash = ? AND attempted_at_utc >= ?`
)
.get(kind, addressHash, since) as { count: number };
if (row.count >= limit) return false;
database
.prepare(
`INSERT INTO oauth_rate_limit_attempts (id, kind, address_hash, attempted_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), kind, addressHash, nowUtc());
return true;
})();
}
export function getOAuthClient(clientId: string): OAuthClient | null {
if (!clientId || clientId.length > 200) return null;
const row = getDatabase()
.prepare('SELECT id, name, redirect_uris_json FROM oauth_clients WHERE id = ?')
.get(clientId) as OAuthClientRow | undefined;
return row ? clientFromRow(row) : null;
}
export function parseAuthorizationRequest(url: URL): OAuthAuthorizationRequest {
const client = getOAuthClient(url.searchParams.get('client_id') ?? '');
if (!client) throw new OAuthError('invalid_client', 'Der OAuth-Client ist nicht registriert.');
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
if (!client.redirectUris.includes(redirectUri)) {
throw new OAuthError('invalid_request', 'Die Redirect-URI ist nicht registriert.');
}
if (url.searchParams.get('response_type') !== 'code') {
throw new OAuthError('unsupported_response_type', 'Nur response_type=code wird unterstützt.');
}
const scope = url.searchParams.get('scope') ?? '';
if (scope !== MCP_SCOPE)
throw new OAuthError('invalid_scope', 'Der angeforderte Scope ist ungültig.');
const resource = url.searchParams.get('resource') ?? '';
if (resource !== mcpResource()) {
throw new OAuthError('invalid_target', 'Die angeforderte MCP-Ressource ist ungültig.');
}
const codeChallenge = url.searchParams.get('code_challenge') ?? '';
if (
url.searchParams.get('code_challenge_method') !== 'S256' ||
!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)
) {
throw new OAuthError('invalid_request', 'PKCE mit S256 ist erforderlich.');
}
const state = url.searchParams.get('state') ?? '';
if (state.length > 1000) throw new OAuthError('invalid_request', 'Der OAuth-State ist zu lang.');
return { client, redirectUri, state, scope: MCP_SCOPE, resource, codeChallenge };
}
export function authorizationErrorRedirect(url: URL, problem: OAuthError): string | null {
const client = getOAuthClient(url.searchParams.get('client_id') ?? '');
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
if (!client?.redirectUris.includes(redirectUri)) return null;
const target = new URL(redirectUri);
target.searchParams.set('error', problem.code);
target.searchParams.set('error_description', problem.message);
const state = url.searchParams.get('state') ?? '';
if (state && state.length <= 1000) target.searchParams.set('state', state);
target.searchParams.set('iss', oauthIssuer());
return target.toString();
}
export function authorizationRedirect(
request: OAuthAuthorizationRequest,
values: Record<string, string>
): string {
const target = new URL(request.redirectUri);
for (const [key, value] of Object.entries(values)) target.searchParams.set(key, value);
if (request.state) target.searchParams.set('state', request.state);
target.searchParams.set('iss', oauthIssuer());
return target.toString();
}
export function createAuthorizationCode(
request: OAuthAuthorizationRequest,
user: AuthUser
): string {
const code = randomToken();
const timestamp = nowUtc();
const database = getDatabase();
const store = database.transaction(() => {
database
.prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?')
.run(timestamp);
database
.prepare(
`INSERT INTO oauth_authorization_codes (
code_hash, client_id, user_id, redirect_uri, scope, resource, code_challenge,
created_at_utc, expires_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
tokenHash(code),
request.client.id,
user.id,
request.redirectUri,
request.scope,
request.resource,
request.codeChallenge,
timestamp,
futureUtc(AUTHORIZATION_CODE_DURATION_MS)
);
});
store();
return code;
}
function pkceChallenge(verifier: string): string {
return createHash('sha256').update(verifier).digest('base64url');
}
export function exchangeAuthorizationCode(input: {
code: string;
clientId: string;
redirectUri: string;
codeVerifier: string;
resource: string;
}) {
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier)) {
throw new OAuthError('invalid_grant', 'Der PKCE-Verifier ist ungültig.');
}
const database = getDatabase();
const exchange = database.transaction(() => {
const timestamp = nowUtc();
const row = database
.prepare(
`SELECT client_id, user_id, redirect_uri, scope, resource, code_challenge,
expires_at_utc, consumed_at_utc
FROM oauth_authorization_codes WHERE code_hash = ?`
)
.get(tokenHash(input.code)) as AuthorizationCodeRow | undefined;
if (
!row ||
row.consumed_at_utc ||
row.expires_at_utc <= timestamp ||
row.client_id !== input.clientId ||
row.redirect_uri !== input.redirectUri ||
row.resource !== input.resource ||
row.code_challenge !== pkceChallenge(input.codeVerifier)
) {
throw new OAuthError('invalid_grant', 'Der Autorisierungscode ist ungültig oder abgelaufen.');
}
const user = database
.prepare('SELECT 1 FROM users WHERE id = ? AND locked_at_utc IS NULL')
.get(row.user_id);
if (!user) throw new OAuthError('invalid_grant', 'Das zugehörige Konto ist nicht aktiv.');
const token = randomToken();
database
.prepare('UPDATE oauth_authorization_codes SET consumed_at_utc = ? WHERE code_hash = ?')
.run(timestamp, tokenHash(input.code));
database
.prepare(
`INSERT INTO oauth_access_tokens (
token_hash, client_id, user_id, scope, resource, created_at_utc, expires_at_utc,
request_window_started_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
tokenHash(token),
row.client_id,
row.user_id,
row.scope,
row.resource,
timestamp,
futureUtc(ACCESS_TOKEN_DURATION_SECONDS * 1000),
timestamp
);
return { accessToken: token, scope: row.scope };
});
return exchange();
}
export function authenticateOAuth(request: Request): AuthUser {
const authorization = request.headers.get('authorization') ?? '';
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
if (!token || token.length > 100)
throw new OAuthError('invalid_token', 'OAuth-Token fehlt.', 401);
const timestamp = nowUtc();
const database = getDatabase();
const row = database.transaction(() => {
const current = database
.prepare(
`SELECT u.id, u.email, u.display_name AS displayName, u.role,
u.locked_at_utc AS lockedAtUtc, t.token_hash,
t.request_window_started_at_utc, t.request_count
FROM oauth_access_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.revoked_at_utc IS NULL AND t.expires_at_utc > ?
AND t.scope = ? AND t.resource = ? AND u.locked_at_utc IS NULL`
)
.get(tokenHash(token), timestamp, MCP_SCOPE, mcpResource()) as
| (AuthUser & {
token_hash: string;
request_window_started_at_utc: string;
request_count: number;
})
| undefined;
if (!current) {
throw new OAuthError('invalid_token', 'OAuth-Token ist ungültig oder abgelaufen.', 401);
}
const windowExpired =
current.request_window_started_at_utc <= new Date(Date.now() - 60 * 60 * 1000).toISOString();
if (!windowExpired && current.request_count >= MCP_REQUEST_LIMIT_PER_HOUR) {
throw new OAuthError('temporarily_unavailable', 'Zu viele MCP-Anfragen.', 429);
}
database
.prepare(
`UPDATE oauth_access_tokens SET last_used_at_utc = ?,
request_window_started_at_utc = ?, request_count = ? WHERE token_hash = ?`
)
.run(
timestamp,
windowExpired ? timestamp : current.request_window_started_at_utc,
windowExpired ? 1 : current.request_count + 1,
current.token_hash
);
return current;
})();
return {
id: row.id,
email: row.email,
displayName: row.displayName,
role: row.role,
lockedAtUtc: row.lockedAtUtc
};
}
export function revokeOAuthToken(token: string, clientId: string): void {
if (!token || token.length > 200 || !clientId || clientId.length > 200) return;
getDatabase()
.prepare(
`UPDATE oauth_access_tokens SET revoked_at_utc = ?
WHERE token_hash = ? AND client_id = ? AND revoked_at_utc IS NULL`
)
.run(nowUtc(), tokenHash(token), clientId);
}
export function oauthTokenDurationSeconds(): number {
return ACCESS_TOKEN_DURATION_SECONDS;
}
+11 -1
View File
@@ -13,6 +13,7 @@ import {
getSharedMedia,
getSharedShow,
revokeShare,
replaceShareToken,
updateShare
} from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js';
@@ -138,8 +139,17 @@ describe('show shares', () => {
expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
const replacementToken = replaceShareToken(actor, shared.id, created.id);
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
expect(getSharedShow(replacementToken).status).toBe('password');
const replacementSession = await authenticateShare(
replacementToken,
'neues-freigabe-passwort',
'192.0.2.20'
);
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('unavailable');
});
it('projects exactly the selected information scope', async () => {
+21
View File
@@ -193,6 +193,27 @@ export async function updateShare(
})();
}
export function replaceShareToken(
actor: AuthUser,
shareId: string,
expectedShowId?: string
): string {
requireBarkeeperActor(actor);
const token = createToken();
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare(
`UPDATE show_shares SET token_hash = ?
WHERE id = ? AND revoked_at_utc IS NULL${expectedShowId ? ' AND show_id = ?' : ''}`
)
.run(hashToken(token), ...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
return token;
}
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor);
const result = getDatabase()
+47 -1
View File
@@ -13,6 +13,7 @@ import {
duplicatePieceUsage,
getPieceUsageContexts,
getShow,
listPreparationUsages,
listShowTemplates,
movePieceUsage,
moveShowSection,
@@ -60,12 +61,57 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId,
preparationStatus: edited.preparationStatus,
preparationEffort: edited.preparationEffort,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds
};
}
describe('show builder data layer', () => {
it('tracks preparation on the concrete usage independently from its original', () => {
const database = getDatabase();
database
.prepare(
"UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'"
)
.run();
database.prepare("UPDATE pieces SET status = 'preparing' WHERE id = 'piece-sounds'").run();
const show = getShow('show-october')!;
const planned = addPieceUsage(show.id, show.revision, {
pieceId: 'piece-console',
sectionId: show.sections[0].id
});
database
.prepare(
"UPDATE pieces SET status = 'ready', preparation_effort = 'low' WHERE id = 'piece-console'"
)
.run();
const usages = listPreparationUsages();
expect(usages.map((usage) => usage.usageId)).toEqual([planned.id]);
expect(usages[0]).toMatchObject({
showId: 'show-october',
showTitle: 'Herbstsendung: Kleine Welten',
sectionName: show.sections[0].name,
title: 'Die kleine Konsole mit dem großen Dreh',
preparationEffort: 'high',
topics: [{ name: 'Gaming' }]
});
expect(usages.some((usage) => usage.title === 'Drei Sounds, ein Spiel')).toBe(false);
const plannedUsage = getShow(show.id)!.sections[0].usages.find(
(usage) => usage.id === planned.id
)!;
updatePieceUsage(
show.id,
planned.id,
planned.revision,
editedUsage(plannedUsage, { preparationStatus: 'ready' })
);
expect(listPreparationUsages()).toEqual([]);
});
it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
@@ -135,7 +181,7 @@ describe('show builder data layer', () => {
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
expect(getShow(blank.id)).toMatchObject({
title: 'Neue Sendung',
title: 'Neuer Stream',
startsAtUtc: null,
sections: [],
reserve: []
+171 -26
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { createPiece } from './content.js';
import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
import { detectVideoProvider, type PresentationMode, type VideoProvider } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
@@ -10,6 +10,8 @@ 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 UsagePreparationStatus = 'preparing' | 'ready';
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type ShowListItem = {
id: string;
@@ -21,6 +23,17 @@ export type ShowListItem = {
sectionCount: number;
};
export type PreparationUsage = {
usageId: string;
showId: string;
showTitle: string;
showStartsAtUtc: string | null;
sectionName: string;
title: string;
preparationEffort: PreparationEffort;
topics: UsageTopic[];
};
export type ShowTemplateSection = {
id: string;
name: string;
@@ -42,6 +55,8 @@ export type PieceUsage = {
originalPieceId: string | null;
position: number;
status: UsageStatus;
preparationStatus: UsagePreparationStatus;
preparationEffort: PreparationEffort;
durationSeconds: number;
title: string;
outputTitle: string;
@@ -59,7 +74,7 @@ export type PieceUsage = {
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProvider: VideoProvider | '';
presentationProviderId: string;
ownNotes: string;
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
@@ -175,6 +190,8 @@ export type PieceUsageUpdate = {
presentationSiteName: string;
ownNotes: string;
durationSeconds: number;
preparationStatus?: UsagePreparationStatus;
preparationEffort?: PreparationEffort;
tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null;
};
@@ -220,6 +237,8 @@ type UsageRow = {
original_piece_id: string | null;
position: number;
status: UsageStatus;
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
duration_seconds: number;
title_snapshot: string;
output_title_snapshot: string;
@@ -238,7 +257,7 @@ type UsageRow = {
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: string;
presentation_provider_snapshot: VideoProvider | '';
presentation_provider_id_snapshot: string;
own_notes: string;
};
@@ -459,6 +478,8 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
originalPieceId: row.original_piece_id,
position: row.position,
status: row.status,
preparationStatus: row.preparation_status_snapshot,
preparationEffort: row.preparation_effort_snapshot,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
outputTitle: row.output_title_snapshot,
@@ -769,6 +790,50 @@ export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
}));
}
export function listPreparationUsages(): PreparationUsage[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
u.title_snapshot, u.preparation_effort_snapshot
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status IN ('draft', 'ready', 'live')
AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc,
CASE u.preparation_effort_snapshot
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END,
s.position, u.position`
)
.all() as {
usage_id: string;
show_id: string;
show_title: string;
show_starts_at_utc: string | null;
section_name: string;
title_snapshot: string;
preparation_effort_snapshot: PreparationUsage['preparationEffort'];
}[];
const topics = database.prepare(
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
);
return rows.map((row) => ({
usageId: row.usage_id,
showId: row.show_id,
showTitle: row.show_title,
showStartsAtUtc: row.show_starts_at_utc,
sectionName: row.section_name,
title: row.title_snapshot,
preparationEffort: row.preparation_effort_snapshot,
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
name: topic.name,
iconName: topic.icon_name
}))
}));
}
export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase();
const templates = database
@@ -821,6 +886,7 @@ export function getShow(id: string): Show | null {
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.preparation_status_snapshot, u.preparation_effort_snapshot,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
@@ -955,7 +1021,7 @@ export function getPieceUsageContexts(
export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase();
const id = randomUUID();
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
const title = cleanText(input.title ?? 'Neuer Stream', 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString();
const create = database.transaction(() => {
@@ -1083,8 +1149,9 @@ export function duplicateCompletedShow(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
@@ -1127,6 +1194,8 @@ export function duplicateCompletedShow(
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot,
usage.preparation_status_snapshot,
usage.preparation_effort_snapshot,
usage.own_notes,
usage.is_reserve
);
@@ -1306,7 +1375,8 @@ export function addPieceUsage(
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.preparation_effort
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?`
)
@@ -1332,6 +1402,7 @@ export function addPieceUsage(
presentation_provider: string;
presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
@@ -1342,7 +1413,7 @@ export function addPieceUsage(
) {
throw new Error('NOT_FOUND');
}
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
if (!['preparing', 'ready'].includes(piece.status)) throw new Error('PIECE_NOT_READY');
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
@@ -1359,8 +1430,9 @@ export function addPieceUsage(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -1387,6 +1459,8 @@ export function addPieceUsage(
piece.presentation_site_name,
piece.presentation_provider,
piece.presentation_provider_id,
piece.status,
piece.preparation_effort,
input.sectionId === null ? 1 : 0
);
snapshotPieceRelations(database, id, input.pieceId);
@@ -1468,6 +1542,7 @@ export function duplicatePieceUsage(
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?`
)
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
@@ -1490,8 +1565,9 @@ export function duplicatePieceUsage(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -1518,6 +1594,8 @@ export function duplicatePieceUsage(
source.presentation_site_name_snapshot,
source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot,
source.preparation_status_snapshot,
source.preparation_effort_snapshot,
source.own_notes,
source.is_reserve
);
@@ -1546,7 +1624,8 @@ export function refreshPieceUsageFromOriginal(
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.status, p.preparation_effort
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position
@@ -1573,9 +1652,12 @@ export function refreshPieceUsageFromOriginal(
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
}
| undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
@@ -1585,7 +1667,8 @@ export function refreshPieceUsageFromOriginal(
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?`
presentation_provider_id_snapshot = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(
source.title,
@@ -1608,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
source.presentation_site_name,
source.presentation_provider,
source.presentation_provider_id,
source.status,
source.preparation_effort,
usageId
);
snapshotPieceRelations(
@@ -1632,6 +1717,18 @@ export function updatePieceUsage(
const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!;
if (
input.preparationStatus !== undefined &&
!['preparing', 'ready'].includes(input.preparationStatus)
) {
throw new Error('INVALID_PREPARATION_STATUS');
}
if (
input.preparationEffort !== undefined &&
![null, 'low', 'medium', 'high'].includes(input.preparationEffort)
) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
@@ -1642,7 +1739,7 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
};
const presentation = validPresentation(
const submittedPresentation = validPresentation(
input.presentationMode,
input.presentationSourceLabel,
input.presentationSourceUrl,
@@ -1656,6 +1753,57 @@ export function updatePieceUsage(
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
const currentPreparation = database
.prepare(
`SELECT preparation_status_snapshot, preparation_effort_snapshot
FROM piece_usages WHERE id = ?`
)
.get(usageId) as {
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
};
let presentation = submittedPresentation;
if (presentation.mode === 'link' || presentation.mode === 'video') {
const source = database
.prepare(
`SELECT label, url, timecode_seconds FROM usage_source_snapshots
WHERE usage_id = ? AND url = ?`
)
.get(usageId, presentation.sourceUrl) as
{ label: string; url: string; timecode_seconds: number | null } | undefined;
if (!source) throw new Error('INVALID_PRESENTATION');
presentation = validPresentation(
presentation.mode,
source.label,
source.url,
source.timecode_seconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName
);
}
const selectedMediaId =
input.presentationMediaId === undefined
? ((
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(usageId) as { id: string } | undefined
)?.id ?? null)
: input.presentationMediaId;
if (
selectedMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(selectedMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
if (presentation.mode === 'image' && !selectedMediaId) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
@@ -1665,7 +1813,8 @@ export function updatePieceUsage(
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?`
own_notes = ?, duration_seconds = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(
title,
@@ -1689,6 +1838,10 @@ export function updatePieceUsage(
presentation.providerId,
values.ownNotes,
duration,
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
input.preparationEffort === undefined
? currentPreparation.preparation_effort_snapshot
: input.preparationEffort,
usageId
);
if (input.tools) {
@@ -1721,23 +1874,15 @@ export function updatePieceUsage(
}
}
if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId);
if (input.presentationMediaId !== null) {
if (selectedMediaId !== null) {
database
.prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
)
.run(input.presentationMediaId, usageId);
.run(selectedMediaId, usageId);
}
}
return result;
+103
View File
@@ -0,0 +1,103 @@
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 type { AuthUser } from './auth.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateTechnical,
createTechnicalAccess,
idempotentResponse,
revokeTechnicalAccess,
updateTechnicalAccess
} from './technical-access.js';
let directory: string;
let actor: AuthUser;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-api-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 });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc) VALUES ('test-user', 'test@example.org',
'test@example.org', 'Test', 'not-used', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
actor = {
id: 'test-user',
email: 'test@example.org',
displayName: 'Test',
role: 'barkeeper',
lockedAtUtc: null
};
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function request(token: string, key?: string) {
return new Request('https://sendewerk.test/api/v1/pieces', {
headers: { Authorization: `Bearer ${token}`, ...(key ? { 'Idempotency-Key': key } : {}) }
});
}
describe('technical API access', () => {
it('enforces permissions and revocation without storing the raw token', () => {
const created = createTechnicalAccess(actor, 'Leser', 'read');
expect(authenticateTechnical(request(created.token))).toMatchObject({
name: 'Leser',
permissions: 'read'
});
expect(() => authenticateTechnical(request(created.token), true)).toThrow('API_FORBIDDEN');
expect(
getDatabase()
.prepare('SELECT token_hash FROM technical_accesses WHERE id = ?')
.get(created.id)
).not.toMatchObject({ token_hash: created.token });
updateTechnicalAccess(actor, created.id, 'Leser bearbeitet', 'read_write');
expect(authenticateTechnical(request(created.token), true)).toMatchObject({
name: 'Leser bearbeitet',
permissions: 'read_write'
});
revokeTechnicalAccess(actor, created.id);
expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED');
});
it('replays an identical idempotent write and rejects a changed payload', () => {
const created = createTechnicalAccess(actor, 'Schreiber', 'read_write');
const technical = authenticateTechnical(request(created.token), true);
let calls = 0;
const first = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
const replay = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
expect(first.duplicate).toBe(false);
expect(replay).toMatchObject({ duplicate: true, body: { calls: 1 } });
expect(calls).toBe(1);
expect(() =>
idempotentResponse(technical, request(created.token, 'command-1'), { title: 'B' }, () => ({
status: 201,
body: {}
}))
).toThrow('API_IDEMPOTENCY_CONFLICT');
});
});
+176
View File
@@ -0,0 +1,176 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type TechnicalPermission = 'read' | 'read_write';
export type TechnicalActor = { id: string; name: string; permissions: TechnicalPermission };
function tokenHash(token: string) {
return createHash('sha256').update(token).digest('hex');
}
export function listTechnicalAccesses() {
return getDatabase()
.prepare(
`SELECT id, name, permissions, created_at_utc AS createdAtUtc,
last_used_at_utc AS lastUsedAtUtc, revoked_at_utc AS revokedAtUtc FROM technical_accesses ORDER BY name`
)
.all() as {
id: string;
name: string;
permissions: TechnicalPermission;
createdAtUtc: string;
lastUsedAtUtc: string | null;
revokedAtUtc: string | null;
}[];
}
export function createTechnicalAccess(actor: AuthUser, nameValue: string, permissions: string) {
const name = nameValue.trim();
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
throw new Error('INVALID_ACCESS');
const token = randomBytes(32).toString('base64url');
const id = randomUUID();
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO technical_accesses
(id, name, token_hash, permissions, created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(id, name, tokenHash(token), permissions, actor.id, now);
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.create',
'technical_access',
id,
'user'
);
return { id, token };
}
export function updateTechnicalAccess(
actor: AuthUser,
id: string,
nameValue: string,
permissions: string
) {
const name = nameValue.trim();
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
throw new Error('INVALID_ACCESS');
const result = getDatabase()
.prepare(
`UPDATE technical_accesses SET name = ?, permissions = ?
WHERE id = ? AND revoked_at_utc IS NULL`
)
.run(name, permissions, id);
if (!result.changes) throw new Error('NOT_FOUND');
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.update',
'technical_access',
id,
'user'
);
}
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
const result = getDatabase()
.prepare(
'UPDATE technical_accesses SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL'
)
.run(new Date().toISOString(), id);
if (!result.changes) throw new Error('NOT_FOUND');
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.revoke',
'technical_access',
id,
'user'
);
}
export function authenticateTechnical(request: Request, write = false): TechnicalActor {
const authorization = request.headers.get('authorization') ?? '';
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
if (!token) throw new Error('API_UNAUTHORIZED');
const row = getDatabase()
.prepare(
`SELECT id, name, permissions FROM technical_accesses
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(tokenHash(token)) as TechnicalActor | undefined;
if (!row) throw new Error('API_UNAUTHORIZED');
if (write && row.permissions !== 'read_write') throw new Error('API_FORBIDDEN');
getDatabase()
.prepare('UPDATE technical_accesses SET last_used_at_utc = ? WHERE id = ?')
.run(new Date().toISOString(), row.id);
return row;
}
export function auditTechnical(
actor: TechnicalActor,
action: string,
targetType: string,
targetId: string,
actorType: 'technical' | 'user' = 'technical'
) {
getDatabase()
.prepare(
`INSERT INTO audit_events (id, actor_type, actor_id, action, target_type, target_id, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
actorType,
actor.id,
action.slice(0, 120),
targetType.slice(0, 80),
targetId.slice(0, 200),
new Date().toISOString()
);
}
export function idempotentResponse(
actor: TechnicalActor,
request: Request,
body: unknown,
operation: () => { status: number; body: unknown }
) {
const key = request.headers.get('idempotency-key')?.trim() ?? '';
if (!key || key.length > 200) throw new Error('API_IDEMPOTENCY_REQUIRED');
const hash = createHash('sha256')
.update(`${request.method} ${new URL(request.url).pathname}\n${JSON.stringify(body)}`)
.digest('hex');
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare(
'SELECT request_hash, status_code, response_json FROM api_idempotency WHERE access_id = ? AND request_key = ?'
)
.get(actor.id, key) as
{ request_hash: string; status_code: number; response_json: string } | undefined;
if (existing) {
if (existing.request_hash !== hash) throw new Error('API_IDEMPOTENCY_CONFLICT');
return {
status: existing.status_code,
body: JSON.parse(existing.response_json),
duplicate: true
};
}
const result = operation();
database
.prepare(
`INSERT INTO api_idempotency
(access_id, request_key, request_hash, status_code, response_json, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
actor.id,
key,
hash,
result.status,
JSON.stringify(result.body),
new Date().toISOString()
);
return { ...result, duplicate: false };
})();
}
+1
View File
@@ -7,5 +7,6 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({
: locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'),
oauthMode: url.pathname === '/oauth/authorize',
liveControllerMode: url.pathname === '/live'
});
+38 -39
View File
@@ -5,22 +5,21 @@
import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import UserMenu from '#lib/components/UserMenu.svelte';
let { children, data } = $props();
const primaryNavigation = [
{ href: '/', label: 'Start', icon: 'house' },
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
{ href: '/fundus', label: 'Pieces', icon: 'library' },
{ href: '/sendungen', label: 'Streams', icon: 'shows' },
{ href: '/live', label: 'Live', icon: 'tower-broadcast' }
] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
return (
page.url.pathname.startsWith(href) || (href === '/sendungen' && page.url.pathname === '/live')
page.url.pathname.startsWith(href) || (href === '/fundus' && page.url.pathname === '/archiv')
);
}
</script>
@@ -28,13 +27,13 @@
<svelte:head>
<meta
name="description"
content="Sendewerk organisiert redaktionelle Inhalte und heinzlive-Sendungen."
content="Sendewerk organisiert redaktionelle Pieces und heinzlive-Streams."
/>
</svelte:head>
{#if data.presentationMode}
{@render children()}
{:else if data.guestMode}
{:else if data.guestMode || data.oauthMode}
<div class="guest-shell">
<header class="auth-header">
<span class="brand"
@@ -49,8 +48,8 @@
<main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user}
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<header class="app-header">
<a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
@@ -68,47 +67,47 @@
{/each}
</nav>
<div class="sidebar-footer">
{#if data.user.role === 'barkeeper'}
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
class:active={isActive(administration.href)}
aria-current={isActive(administration.href) ? 'page' : undefined}
>
<NavIcon name={administration.icon} />
<span>{administration.label}</span>
</a>
</nav>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
<div class="app-header-actions">
<form class="global-search-form" method="GET" action={resolve('/suche')} role="search">
<label>
<AppIcon name="magnifying-glass" />
<span class="sr-only">Pieces und Streams durchsuchen</span>
<input
name="q"
type="search"
value={page.url.pathname === '/suche' ? (page.url.searchParams.get('q') ?? '') : ''}
placeholder="Pieces und Streams suchen"
/>
</label>
</form>
<ThemeToggle />
<UserMenu displayName={data.user.displayName} role={data.user.role} />
</div>
</aside>
</header>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden"
><AppIcon name="right-from-bracket" /></button
<form
class="global-search-form mobile-global-search"
method="GET"
action={resolve('/suche')}
role="search"
>
<label>
<AppIcon name="magnifying-glass" />
<span class="sr-only">Pieces und Streams durchsuchen</span>
<input name="q" type="search" placeholder="Suchen" />
</label>
</form>
<ThemeToggle />
<UserMenu displayName={data.user.displayName} role={data.user.role} />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)}
{#each primaryNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
+32 -1
View File
@@ -1,7 +1,38 @@
import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js';
import { listPieces } from '#lib/server/content.js';
import { getPieceUsageContexts, getShow, listShows } from '#lib/server/shows.js';
export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login');
if (!locals.user) redirect(307, '/login');
const now = new Date().toISOString();
const activeShows = listShows(['draft', 'ready', 'live']);
const orderedShows = [...activeShows].sort((left, right) => {
if (left.status === 'live' && right.status !== 'live') return -1;
if (right.status === 'live' && left.status !== 'live') return 1;
if (left.startsAtUtc && right.startsAtUtc) {
const leftPast = left.startsAtUtc < now;
const rightPast = right.startsAtUtc < now;
if (leftPast !== rightPast) return leftPast ? 1 : -1;
return leftPast
? right.startsAtUtc.localeCompare(left.startsAtUtc)
: left.startsAtUtc.localeCompare(right.startsAtUtc);
}
return left.startsAtUtc ? -1 : right.startsAtUtc ? 1 : 0;
});
const nextShowSummary = orderedShows[0] ?? null;
const nextShow = nextShowSummary ? getShow(nextShowSummary.id) : null;
const pieceCandidates = listPieces({ statuses: ['preparing', 'ready'] });
const contexts = getPieceUsageContexts(pieceCandidates.map((piece) => piece.id));
const currentPieces = pieceCandidates
.filter((piece) => !(contexts[piece.id]?.sentHistory.length > 0) || piece.isFavorite)
.slice(0, 6);
return {
nextShow,
currentPieces,
upcomingShows: orderedShows.filter((show) => show.id !== nextShowSummary?.id).slice(0, 4)
};
}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import { formatDuration } from '#lib/format.js';
let { data } = $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));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Sendefertig';
if (status === 'live') return 'Live';
return 'In Vorbereitung';
}
</script>
<svelte:head><title>Start | Sendewerk</title></svelte:head>
<section class="page-wrap dashboard-page">
<header class="dashboard-heading">
<div>
<p class="eyebrow">Redaktion</p>
<h1>Was als Nächstes ansteht</h1>
</div>
<nav class="dashboard-quick-actions" aria-label="Schnellaktionen">
<a class="secondary-button" href={resolve('/fundus?create=1')}
><AppIcon name="plus" /> Neues Piece</a
>
<a class="primary-button" href={resolve('/sendungen?create=1')}
><AppIcon name="calendar-plus" /> Neuen Stream planen</a
>
</nav>
</header>
<div class="dashboard-grid">
<section class="dashboard-next-stream" aria-labelledby="next-stream-heading">
<header>
<div>
<p class="eyebrow">Nächster Stream</p>
<h2 id="next-stream-heading">{data.nextShow?.title ?? 'Noch kein Stream geplant'}</h2>
{#if data.nextShow}<p>
{berlinDate(data.nextShow.startsAtUtc)} Uhr · {statusLabel(data.nextShow.status)}
</p>{/if}
</div>
{#if data.nextShow}<a
class="text-link"
href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
>Planung öffnen <AppIcon name="arrow-right" /></a
>{/if}
</header>
{#if data.nextShow}
<div class="dashboard-run-list">
{#each data.nextShow.sections as section (section.id)}
<div class="dashboard-run-section">
<span class="run-marker" style:--marker={section.categoryColor ?? 'var(--gold)'}
></span>
<div>
<strong>{section.name}</strong>
<small>{formatDuration(section.durationSeconds)}</small>
{#if section.usages.length}<p>
{section.usages.map((usage) => usage.title).join(' · ')}
</p>{/if}
</div>
</div>
{/each}
{#if !data.nextShow.sections.length}<p class="empty-state">
Der Ablauf ist noch leer.
</p>{/if}
</div>
<div class="dashboard-stream-actions">
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
><AppIcon name="pen" /> Ablauf bearbeiten</a
>
<a
class="primary-button"
href={resolve(`/live?show=${encodeURIComponent(data.nextShow.id)}`)}
><AppIcon name="tower-broadcast" /> Live öffnen</a
>
</div>
{:else}
<div class="empty-state compact">
<p>Lege einen Stream an, um hier den nächsten Ablauf zu sehen.</p>
</div>
{/if}
</section>
<aside class="dashboard-side-stack">
<section aria-labelledby="upcoming-heading">
<header>
<h2 id="upcoming-heading">Weitere Streams</h2>
<a href={resolve('/sendungen')}>Alle</a>
</header>
{#if data.upcomingShows.length}
<div class="dashboard-mini-list">
{#each data.upcomingShows as show (show.id)}
<a href={resolve('/sendungen/[id]', { id: show.id })}
><span
><strong>{show.title}</strong><small>{berlinDate(show.startsAtUtc)} Uhr</small
></span
><AppIcon name="chevron-right" /></a
>
{/each}
</div>
{:else}<p class="empty-state compact">Keine weiteren Streams geplant.</p>{/if}
</section>
</aside>
</div>
<section class="dashboard-pieces" aria-labelledby="current-pieces-heading">
<header>
<div>
<p class="eyebrow">Pieces</p>
<h2 id="current-pieces-heading">Aktuell in Arbeit</h2>
</div>
<a href={resolve('/fundus')}>Alle Pieces <AppIcon name="arrow-right" /></a>
</header>
{#if data.currentPieces.length}
<div class="dashboard-piece-grid">
{#each data.currentPieces as piece (piece.id)}
<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span class={`show-state ${piece.status}`}
>{piece.status === 'ready' ? 'Bereit' : 'In Vorbereitung'}</span
>
<strong>{piece.title}</strong>
<p>{piece.summary || piece.format}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine aktuellen Pieces.</p>{/if}
</section>
</section>
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MCP_SCOPE, oauthIssuer } from '#lib/server/oauth.js';
export const GET: RequestHandler = () => {
const issuer = oauthIssuer();
return json(
{
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/oauth/token`,
registration_endpoint: `${issuer}/oauth/register`,
revocation_endpoint: `${issuer}/oauth/revoke`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
token_endpoint_auth_methods_supported: ['none'],
revocation_endpoint_auth_methods_supported: ['none'],
code_challenge_methods_supported: ['S256'],
scopes_supported: [MCP_SCOPE],
authorization_response_iss_parameter_supported: true
},
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
);
};
@@ -0,0 +1 @@
export { GET } from './mcp/+server.js';
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MCP_SCOPE, mcpResource, oauthIssuer } from '#lib/server/oauth.js';
export const GET: RequestHandler = () =>
json(
{
resource: mcpResource(),
authorization_servers: [oauthIssuer()],
scopes_supported: [MCP_SCOPE],
bearer_methods_supported: ['header']
},
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
);
+105
View File
@@ -0,0 +1,105 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const usageId = { name: 'usageId', in: 'path', required: true, schema: { type: 'string' } };
const idempotency = {
name: 'Idempotency-Key',
in: 'header',
required: true,
schema: { type: 'string' }
};
const ok = { '200': { description: 'Erfolgreich' } };
const jsonBody = (schema: Record<string, unknown>) => ({
required: true,
content: { 'application/json': { schema } }
});
export const GET: RequestHandler = () =>
json({
openapi: '3.1.0',
info: {
title: 'Sendewerk API',
version: '1.0.0',
description:
'Versionierte API für technische Redaktionszugänge. Schreibzugriffe benötigen einen Idempotency-Key.'
},
servers: [{ url: '/api/v1' }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'Sendewerk technical token' }
},
schemas: {
PieceWrite: {
type: 'object',
properties: {
title: { type: 'string' },
summary: { type: 'string' },
status: { enum: ['inbox', 'preparing', 'ready'] },
durationSeconds: { type: 'integer', minimum: 0 }
}
},
ShowWrite: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
startsAtUtc: { type: ['string', 'null'], format: 'date-time' },
templateId: { type: ['string', 'null'] }
}
}
}
},
paths: {
'/pieces': {
get: { summary: 'Pieces lesen', responses: ok },
post: {
summary: 'Piece anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/PieceWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/pieces/{id}': {
get: { summary: 'Piece lesen', parameters: [id], responses: ok },
patch: {
summary: 'Piece ändern',
parameters: [id],
requestBody: jsonBody({
allOf: [
{ $ref: '#/components/schemas/PieceWrite' },
{ type: 'object', required: ['revision'] }
]
}),
responses: ok
}
},
'/shows': {
get: { summary: 'Streams lesen', responses: ok },
post: {
summary: 'Stream anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/shows/{id}': { get: { summary: 'Stream lesen', parameters: [id], responses: ok } },
'/shows/{id}/usages': {
post: {
summary: 'Piece einplanen',
parameters: [id, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'pieceId'] }),
responses: { '201': { description: 'Eingeplant' } }
}
},
'/shows/{id}/usages/{usageId}': {
patch: {
summary: 'Einplanung sortieren',
parameters: [id, usageId, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'position'] }),
responses: ok
}
}
}
});
+69
View File
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import {
createPiece,
getPiece,
listPieces,
savePiece,
type PieceStatus
} from '#lib/server/content.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const status = url.searchParams.get('status') as PieceStatus | null;
const statuses = status ? [status] : (['inbox', 'preparing', 'ready'] as PieceStatus[]);
const all = listPieces({ statuses, query: url.searchParams.get('q') ?? '' });
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title: String(body.title ?? ''),
summary: String(body.summary ?? ''),
status: ['inbox', 'preparing', 'ready'].includes(String(body.status))
? (body.status as 'inbox' | 'preparing' | 'ready')
: 'preparing',
durationSeconds: Number(body.durationSeconds ?? 0),
sources: Array.isArray(body.sources)
? body.sources.map((source) => ({
label: String((source as Record<string, unknown>).label ?? ''),
url: String((source as Record<string, unknown>).url ?? ''),
timecodeSeconds:
(source as Record<string, unknown>).timecodeSeconds == null
? null
: Number((source as Record<string, unknown>).timecodeSeconds)
}))
: []
});
auditTechnical(actor, 'piece.create', 'piece', id);
return { status: 201, body: { data: getPiece(id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+44
View File
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getPiece, savePiece } from '#lib/server/content.js';
import { auditTechnical, authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
return json({ data: piece });
} catch (cause) {
return apiFailure(cause);
}
};
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
if (piece.status === 'archived') throw new Error('INVALID_STATUS');
if (Number(body.revision) !== piece.revision) throw new Error('CONFLICT');
const status =
body.status !== undefined
? (String(body.status) as 'inbox' | 'preparing' | 'ready')
: piece.status;
savePiece(params.id, piece.revision, {
...piece,
status,
...(body.title !== undefined ? { title: String(body.title) } : {}),
...(body.summary !== undefined ? { summary: String(body.summary) } : {}),
...(body.durationSeconds !== undefined
? { durationSeconds: Number(body.durationSeconds) }
: {})
});
auditTechnical(actor, 'piece.update', 'piece', params.id);
return json({ data: getPiece(params.id) });
} catch (cause) {
return apiFailure(cause);
}
};
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import { createShow, getShow, listShows } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const all = listShows();
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = createShow({
title: String(body.title ?? ''),
startsAtUtc: body.startsAtUtc ? String(body.startsAtUtc) : null,
templateId: body.templateId ? String(body.templateId) : null
});
auditTechnical(actor, 'show.create', 'show', created.id);
return { status: 201, body: { data: getShow(created.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+16
View File
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiFailure } from '#lib/server/api-http.js';
import { getShow } from '#lib/server/shows.js';
import { authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const show = getShow(params.id);
if (!show) throw new Error('NOT_FOUND');
return json({ data: show });
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { addPieceUsage, getShow } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const POST: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = addPieceUsage(params.id, Number(body.revision), {
pieceId: String(body.pieceId ?? ''),
sectionId: body.sectionId ? String(body.sectionId) : null,
position: body.position == null ? undefined : Number(body.position)
});
auditTechnical(actor, 'usage.create', 'usage', created.id);
return { status: 201, body: { data: getShow(params.id), usageId: created.id } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getShow, movePieceUsage } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
movePieceUsage(params.id, params.usageId, Number(body.revision), {
sectionId: body.sectionId ? String(body.sectionId) : null,
position: Number(body.position)
});
auditTechnical(actor, 'usage.move', 'usage', params.usageId);
return { status: 200, body: { data: getShow(params.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+1 -1
View File
@@ -50,7 +50,7 @@ export const actions = {
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' });
return fail(400, { error: 'Der Stream konnte nicht dupliziert werden.' });
}
}
} satisfies Actions;
+6 -25
View File
@@ -1,41 +1,22 @@
<script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<svelte:head><title>Archivierte Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Archiv</h1>
<p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p>
<p class="eyebrow">Pieces</p>
<h1>Archiviert und gelöscht</h1>
<p class="page-intro">Bewusst abgelegte Pieces bleiben auffindbar und wiederherstellbar.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
{#each data.pieces as piece (piece.id)}<article id={`piece-${piece.id}`}>
<div>
<h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
@@ -57,7 +38,7 @@
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy">
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
Diese Einträge bleiben wiederherstellbar; bestehende Streamfassungen werden nicht verändert.
</p>
{#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article>
+41 -2
View File
@@ -1,11 +1,16 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { captureInbox, getTaxonomy, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) };
return {
pieces: listPieces({ statuses: ['inbox'] }),
feedEntries: listFeedEntries(),
taxonomy: getTaxonomy()
};
};
export const actions = {
@@ -36,5 +41,39 @@ export const actions = {
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
}
},
importFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length)
return fail(400, {
intent: 'feedImport',
feedError: 'Mindestens einen Beitrag auswählen.'
});
try {
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
return {
intent: 'feedImport',
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
};
} catch {
return fail(400, {
intent: 'feedImport',
feedError: 'Die Auswahl konnte nicht übernommen werden.'
});
}
},
discardFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length)
return fail(400, { intent: 'feedDiscard', feedError: 'Mindestens einen Beitrag auswählen.' });
const count = discardFeedEntries(entryIds);
return {
intent: 'feedDiscard',
feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.`
};
}
} satisfies Actions;
+50
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let rssDialog: { showModal: () => void };
</script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
@@ -43,6 +45,39 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
<section class="quiet-panel rss-inbox-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2>RSS-Beiträge</h2>
</div>
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
</div>
<p class="catalog-note">
{data.feedEntries.length}
{data.feedEntries.length === 1 ? 'Beitrag ist' : 'Beiträge sind'}
verfügbar. Erst deine ausdrückliche Auswahl übernimmt sie in den redaktionellen Eingang.
</p>
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess}
</div>{/if}
<button
class="compact-add-button"
type="button"
disabled={!data.feedEntries.length}
onclick={() => rssDialog.showModal()}
>
<AppIcon name="list" />
{data.feedEntries.length ? 'RSS-Beiträge auswählen' : 'Keine neuen RSS-Beiträge'}
</button>
</section>
<RssSelectionDialog
bind:this={rssDialog}
entries={data.feedEntries}
categories={data.taxonomy.categories}
{form}
/>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
@@ -81,3 +116,18 @@
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div>
<style>
.rss-inbox-panel {
display: grid;
gap: 0.75rem;
margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
}
.rss-inbox-panel .panel-heading {
margin: 0;
}
.rss-inbox-panel .catalog-note {
margin: 0;
}
</style>
+3 -3
View File
@@ -15,7 +15,7 @@
}
</script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head>
<svelte:head><title>Streamfreigabe | Sendewerk</title></svelte:head>
<main class="share-page">
{#if data.status === 'unavailable'}
@@ -38,13 +38,13 @@
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Sendung öffnen</button>
<button class="primary-button" type="submit">Stream öffnen</button>
</form>
</section>
{:else}
<header class="share-heading">
<p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen
{data.share.isCompleted ? 'Abgeschlossener Stream' : 'Aktuelle Streamplanung'} · Nur lesen
</p>
<h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
+8 -34
View File
@@ -5,44 +5,18 @@ import {
createPiece,
deletePieceTemplate,
duplicatePiece,
getTaxonomy,
listPieces,
listPieceTemplates,
setPieceArchived,
setPieceDeleted,
setPieceFavorite
} from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js';
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
};
const pieces = listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
templates: listPieceTemplates(),
filters
...loadFundusWorkspace(url),
templates: listPieceTemplates()
};
};
@@ -54,7 +28,7 @@ function actionError(cause: unknown) {
}
export const actions = {
create: async ({ locals, request }) => {
create: async ({ locals, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
@@ -63,7 +37,7 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
},
deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user);
@@ -75,7 +49,7 @@ export const actions = {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
}
},
duplicate: async ({ locals, request }) => {
duplicate: async ({ locals, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
@@ -84,7 +58,7 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
},
archive: async ({ locals, request }) => {
requireUser(locals.user);
@@ -113,7 +87,7 @@ export const actions = {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return {
intent: 'delete',
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
success: 'Piece gelöscht. Bestehende Streamfassungen bleiben erhalten.'
};
} catch (cause) {
return fail(409, { intent: 'delete', error: actionError(cause) });
+64 -30
View File
@@ -4,8 +4,10 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -15,8 +17,8 @@
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
(showPlannedPieces || !piece.isPlannedInActiveShow)
(data.filters.view === 'completed' || showUsedPieces || piece.sentHistory.length === 0) &&
(data.filters.view === 'completed' || showPlannedPieces || !piece.isPlannedInActiveShow)
)
);
onMount(() => {
@@ -26,9 +28,35 @@
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
const scrollKey = `fundus-scroll:${window.location.search}`;
const storedScroll = Number(sessionStorage.getItem(scrollKey));
if (Number.isFinite(storedScroll))
requestAnimationFrame(() => window.scrollTo(0, storedScroll));
if (new URL(window.location.href).searchParams.get('create') === '1') {
document.querySelector<HTMLDetailsElement>('.template-picker')?.setAttribute('open', '');
}
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function rememberListPosition() {
sessionStorage.setItem(`fundus-scroll:${window.location.search}`, String(window.scrollY));
}
function pieceSearch() {
const search = new SvelteURLSearchParams();
if (data.filters.query) search.set('q', data.filters.query);
if (data.filters.status) search.set('status', data.filters.status);
if (data.filters.formatId) search.set('format', data.filters.formatId);
if (data.filters.topicId) search.set('topic', data.filters.topicId);
if (data.filters.categoryId) search.set('category', data.filters.categoryId);
if (data.filters.favorites) search.set('favorites', '1');
if (data.filters.view) search.set('view', data.filters.view);
if (showUsedPieces) search.set('includeUsed', '1');
if (showPlannedPieces) search.set('includePlanned', '1');
const query = search.toString();
return query ? `?${query}` : '';
}
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
@@ -53,13 +81,16 @@
};
</script>
<svelte:head><title>Fundus | Sendewerk</title></svelte:head>
<svelte:head><title>Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap">
<div class="fundus-workspace fundus-list-workspace">
<FundusNavigation filters={data.filters} counts={data.counts} topics={data.taxonomy.topics} />
<section class="fundus-list-pane">
<header class="page-heading split-heading">
<div>
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
<p class="eyebrow">Redaktionelle Bibliothek</p>
<h1>Pieces</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Streams.</p>
</div>
<div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
@@ -105,22 +136,10 @@
{#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}
<form
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<form id="library-filters" class="library-filters" method="GET" aria-label="Pieces filtern">
<div class="fundus-search-row">
<label class="search-field"
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<button class="secondary-button" type="submit">Suchen</button>
<input type="hidden" name="q" value={data.filters.query} />
{#if data.filters.view}<input type="hidden" name="view" value={data.filters.view} />{/if}
<details class="filter-picker">
<summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel">
@@ -136,7 +155,8 @@
>
<label
><span>Format</span><select name="format"
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
><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
@@ -159,7 +179,7 @@
>{/each}</select
></label
>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<div class="library-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
@@ -176,7 +196,8 @@
name="includePlanned"
value="1"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
onchange={(event) =>
setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
@@ -197,6 +218,12 @@
class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
>
<a
class="piece-row-link"
href={resolve(`/fundus/${encodeURIComponent(piece.id)}${pieceSearch()}` as '/fundus')}
onclick={rememberListPosition}
aria-label={`${piece.title} öffnen`}
></a>
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
<input type="hidden" name="pieceId" value={piece.id} />
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
@@ -214,11 +241,16 @@
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
</span>{/if}
/>{/if}
<div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<h2>
<a
href={resolve(
`/fundus/${encodeURIComponent(piece.id)}${pieceSearch()}` as '/fundus'
)}
onclick={rememberListPosition}>{piece.title}</a
>
</h2>
{#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} />
</div>
@@ -244,7 +276,8 @@
>
</div>
<details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
<summary aria-label={`Aktionen für ${piece.title}`}
><AppIcon name="ellipsis" /></summary
>
<div class="piece-actions-popover">
<form method="POST" action="?/duplicate">
@@ -265,7 +298,7 @@
action="?/delete"
onsubmit={(event) =>
!confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
`Piece „${piece.title}“ löschen? Bestehende Streamfassungen bleiben erhalten.`
) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
@@ -288,4 +321,5 @@
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div>
{/if}
</section>
</div>
+11 -9
View File
@@ -19,6 +19,7 @@ import {
type PieceSourceInput
} from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
import { listTools } from '#lib/server/tools.js';
function renderedFields(
@@ -138,12 +139,13 @@ function saveError(cause: unknown) {
};
}
export const load: PageServerLoad = ({ locals, params }) => {
export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv');
return {
workspace: loadFundusWorkspace(url),
piece,
taxonomy: getTaxonomy(),
tools: listTools(true),
@@ -165,7 +167,7 @@ export const actions = {
return fail(problem.status, { intent: 'save', ...problem });
}
},
loadPreview: async ({ locals, params, request }) => {
loadPreview: async ({ locals, params, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
try {
@@ -188,7 +190,7 @@ export const actions = {
const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem });
}
redirect(303, `/fundus/${params.id}`);
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
},
saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user);
@@ -209,7 +211,7 @@ export const actions = {
return fail(problem.status, { intent: 'saveTemplate', ...problem });
}
},
upload: async ({ locals, params, request }) => {
upload: async ({ locals, params, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
const image = data.get('image');
@@ -239,9 +241,9 @@ export const actions = {
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}`);
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
},
deleteImage: async ({ locals, params, request }) => {
deleteImage: async ({ locals, params, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
try {
@@ -249,9 +251,9 @@ export const actions = {
} catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
}
redirect(303, `/fundus/${params.id}`);
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
},
duplicate: async ({ locals, params }) => {
duplicate: async ({ locals, params, url }) => {
requireUser(locals.user);
let id: string;
try {
@@ -259,7 +261,7 @@ export const actions = {
} catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
}
redirect(303, `/fundus/${id}`);
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
},
archive: async ({ locals, params, request }) => {
requireUser(locals.user);
+116 -58
View File
@@ -2,10 +2,12 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import RichMarkdownField from '#lib/components/RichMarkdownField.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
@@ -77,14 +79,19 @@
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: '',
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : []
images:
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? [{ id: selectedMedia.id, altText: selectedMedia.altText }]
: []
});
let presentationPreviewImage = $derived(
selectedMedia
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? draft.presentationMode === 'text'
? '/media'
: `/media/${selectedMedia.id}`
: sourcePreviewImage
: draft.presentationMode === 'link'
? sourcePreviewImage
: ''
);
let pendingHref: string | null = null;
let allowNavigation = false;
@@ -116,8 +123,8 @@
if (
control instanceof HTMLInputElement &&
control.name === 'presentationMode' &&
control.value !== 'text' &&
draft.presentationSourcePosition === null
((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
(control.value === 'image' && !draft.presentationMediaId))
) {
return;
}
@@ -134,10 +141,12 @@
}
function requestClose() {
const target = resolve('/fundus');
const target = `${resolve('/fundus')}${page.url.search}`;
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
allowNavigation = true;
// The target combines the resolved Fundus route with the current filter query.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(target);
return;
}
@@ -147,6 +156,8 @@
return;
}
allowNavigation = true;
// The target combines the resolved Fundus route with the current filter query.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(target);
}
@@ -241,9 +252,15 @@
};
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:head><title>{draft.title} | Pieces | Sendewerk</title></svelte:head>
<div class="piece-editor-route">
<div class="fundus-workspace fundus-editor-workspace">
<FundusNavigation
filters={data.workspace.filters}
counts={data.workspace.counts}
topics={data.workspace.taxonomy.topics}
/>
<div class="piece-editor-route">
{#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error}
</div>{/if}
@@ -252,17 +269,20 @@
bind:activeSection
labelledby="piece-editor-title"
onrequestclose={requestClose}
inline
>
{#snippet header()}
<div class="piece-editor-kicker">
<p class="eyebrow">Fundusfassung</p>
<p class="eyebrow">Piece-Fassung</p>
<form method="POST" action="?/favorite" use:enhance>
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
<button
class="piece-favorite"
class:active={data.piece.isFavorite}
type="submit"
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
aria-label={data.piece.isFavorite
? 'Aus Favoriten entfernen'
: 'Als Favorit markieren'}
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
>
</form>
@@ -287,42 +307,42 @@
>
<input type="hidden" name="revision" value={revision} />
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<section class="piece-editor-panel piece-script-panel">
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2>
<h2>Streamtext</h2>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
<RichMarkdownField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro}
/>
<MarkdownEditorField
<RichMarkdownField
name="moderationNotes"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderationNotes}
/>
<MarkdownEditorField
<RichMarkdownField
name="interactionText"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interactionText}
/>
<MarkdownEditorField
<RichMarkdownField
name="transitionText"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText}
/>
</div>
<details class="piece-meta-details">
<details class="piece-meta-details" open>
<summary>
<span>Status, Dauer und Einordnung</span>
<small
@@ -348,10 +368,11 @@
/></label
>
<label
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option
value="medium">Mittel</option
><option value="high">Hoch</option></select
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option></select
></label
>
<label
@@ -399,7 +420,7 @@
</details>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<section class="piece-editor-panel piece-research-panel">
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
@@ -407,7 +428,7 @@
</div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<MarkdownEditorField
<RichMarkdownField
name="researchText"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -518,7 +539,7 @@
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<section class="piece-editor-panel piece-presentation-panel">
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
@@ -533,21 +554,7 @@
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<p class="field-label">Präsentationsart</p>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
@@ -558,9 +565,30 @@
/>{mode[1]}</label
>{/each}
</div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<RichMarkdownField
name="presentationText"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentationText" value={draft.presentationText} />
{/if}
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<label
>Quelle<select
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
@@ -571,7 +599,7 @@
></label
>
<label
>Kartentitel<input
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
@@ -579,16 +607,24 @@
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
{#if draft.presentationMode === 'link'}
<label
>Bild-URL<input
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
placeholder="Wird aus der Linkvorschau geladen"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
@@ -596,7 +632,8 @@
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
oninput={() => (draft.presentationDescriptionManual = true)}
></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
@@ -612,6 +649,24 @@
>
</div>
</div>
{:else}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
@@ -638,6 +693,7 @@
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
@@ -646,7 +702,10 @@
</section>
</form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}>
<section
class="piece-editor-panel piece-media-panel"
hidden={!['text', 'image'].includes(draft.presentationMode)}
>
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Hochgeladene Bilder</p>
@@ -654,12 +713,10 @@
</div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div>
{#if data.piece.media.length}<label class="media-none"
{#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value=""
name="presentationMediaChoice"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
@@ -668,13 +725,11 @@
/> Kein hochgeladenes Bild</label
>
<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
{#each draft.media as media (media.id)}<figure>
<label class="media-presentation-choice"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value={media.id}
name="presentationMediaChoice"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
@@ -691,8 +746,10 @@
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
/><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Entfernen</button
>
</form>
</figcaption>
@@ -757,7 +814,7 @@
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
!confirm('Piece löschen? Bestehende Streamfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
@@ -802,4 +859,5 @@
>
<input type="hidden" name="revision" value={revision} />
</form>
</div>
</div>
@@ -14,10 +14,12 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay
content={data.content}
imageSrc={data.content.images.length
? data.content.mode === 'text'
imageSrc={data.content.mode === 'text' && data.content.images.length
? '/media'
: `/media/${data.content.images[0].id}`
: `/fundus/${data.pieceId}/praesentation/preview-image`}
: data.content.mode === 'image' && data.content.images.length
? `/media/${data.content.images[0].id}`
: data.content.mode === 'link' && data.content.imageUrl
? `/fundus/${data.pieceId}/praesentation/preview-image`
: ''}
/>
</main>
@@ -7,7 +7,9 @@ import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar');
if (piece?.presentationMode !== 'link' || !piece.presentationImageUrl) {
error(404, 'Vorschaubild nicht verfügbar');
}
try {
const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), {
+63 -14
View File
@@ -8,6 +8,7 @@ import {
clearPresentationChannel,
endLive,
getLiveView,
getPresentationChannelStatus,
goBack,
issuePresentationAccess,
moveOpenUsage,
@@ -16,6 +17,7 @@ import {
rotatePresentationChannelKey,
resetSectionTimer,
resetTotalTimer,
resumeUsage,
resumeEndedLive,
resumeLive,
selectUsage,
@@ -25,7 +27,13 @@ import {
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) {
@@ -41,7 +49,7 @@ function actionError(cause: unknown) {
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NOT_STARTED')
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
return { status: 409, error: 'Der Stream muss vor der Auswahl gestartet werden.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
@@ -49,16 +57,16 @@ function actionError(cause: unknown) {
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
return { status: 409, error: 'Abgeschlossene Streams können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
return { status: 404, error: 'Stream oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Der Stream ist pausiert.' };
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
if (code === 'NO_SECTION_TIMER')
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
return { status: 409, error: 'Der Stream befindet sich nicht im passenden Zustand.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
}
@@ -75,11 +83,12 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, url }) => {
const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
if (!showId) error(404, 'Kein Stream vorhanden');
try {
const live = getLiveView(showId);
return {
live,
outputStatus: getPresentationChannelStatus(showId),
canManageShares: actor.role === 'barkeeper',
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
commandId: randomUUID(),
@@ -88,7 +97,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
};
} catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden');
error(404, 'Stream nicht gefunden');
throw cause;
}
};
@@ -99,10 +108,16 @@ export const actions = {
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'),
showId: text(data, 'showId'),
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` };
return {
intent: 'createShare',
success: 'Leselink angelegt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem });
@@ -112,16 +127,38 @@ export const actions = {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, text(data, 'shareId'), {
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'),
await updateShare(
actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
}, text(data, 'showId'));
},
text(data, 'showId')
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem });
}
},
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), text(data, 'showId'));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'replaceShare', ...problem });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
@@ -173,7 +210,7 @@ export const actions = {
const data = await request.formData();
const status = text(data, 'status');
if (status !== 'draft' && status !== 'ready') {
return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' });
return fail(400, { intent: 'setStatus', error: 'Der Streamstatus ist ungültig.' });
}
return attempt('setStatus', () =>
setShowStatus(
@@ -253,6 +290,18 @@ export const actions = {
)
);
},
resumeUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resumeUsage', () =>
resumeUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+230 -131
View File
@@ -74,10 +74,10 @@
)
);
let currentSectionTitle = $derived(
currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')
currentSection?.name ?? (sessionComplete ? 'Streamende' : 'Live-Regie')
);
let nextTitle = $derived(
data.live.next?.title ?? (sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')
data.live.next?.title ?? (sessionComplete ? 'Streamende erreicht' : 'Kein weiteres Piece')
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
@@ -300,9 +300,10 @@
menuOpen = true;
expandedContext = null;
await tick();
liveMenuPopover
?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
?.focus({ preventScroll: true });
(
liveMenuPopover?.querySelector<HTMLElement>('[data-menu-initial-focus]') ??
liveMenuPopover?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
)?.focus({ preventScroll: true });
}
async function closeLiveMenu(restoreFocus = true) {
@@ -361,6 +362,15 @@
return 'Kommend';
}
function showStateLabel() {
if (data.live.show.status === 'completed') return 'Historisch abgeschlossen';
if (!data.live.startedAtUtc)
return data.live.show.status === 'ready' ? 'Bereit zum Start' : 'Entwurf';
if (data.live.endedAtUtc) return 'Lauf beendet';
if (data.live.isPaused) return 'Pausiert';
return 'Läuft';
}
function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
}
@@ -481,6 +491,7 @@
</form>{/if}
</div>
<div class="total-timer-control">
<button
class="total-countdown"
type="button"
@@ -503,6 +514,23 @@
>
{/if}
</button>
{#if data.live.startedAtUtc && !data.live.endedAtUtc}<details class="timer-reset-menu">
<summary aria-label="Aktionen für Gesamtzeit"><AppIcon name="ellipsis" /></summary>
<form
method="POST"
action={liveAction('resetTotalTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Gesamtzeit zurücksetzen</button>
</form>
</details>{/if}
</div>
<div class="section-timer-control">
<div
@@ -534,12 +562,30 @@
class:paused={data.live.isPaused}
class="live-timer-toggle"
type="submit"
aria-label={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
title={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
aria-label={data.live.isPaused ? 'Stream fortsetzen' : 'Stream pausieren'}
title={data.live.isPaused ? 'Stream fortsetzen' : 'Stream pausieren'}
>
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
</button>
</form>{/if}
{#if data.live.startedAtUtc && !data.live.endedAtUtc && data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}<details
class="timer-reset-menu"
>
<summary aria-label="Aktionen für Abschnittszeit"><AppIcon name="ellipsis" /></summary>
<form
method="POST"
action={liveAction('resetSectionTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Abschnittszeit zurücksetzen</button>
</form>
</details>{/if}
</div>
<button
@@ -567,47 +613,23 @@
inert={mobileRailOpen}
>
<section class="live-menu-show">
<small>Sendung</small>
<strong>Stream steuern</strong>
<div class="live-menu-show-heading">
<span>
<small>Stream</small>
<strong>{data.live.show.title}</strong>
</section>
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</span>
<span class:paused={data.live.isPaused} class="live-show-state">{showStateLabel()}</span>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement shows={[{ ...data.live.show, startsAtUtc: null }]} shares={data.shares} fixedShowId={data.live.show.id} {form} />{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendungssteuerung</strong>
<small>Status: {data.live.show.status}</small>
{#if !data.live.startedAtUtc}
<form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Stream starten</button
>
</form>
<form method="POST" action={liveAction('setStatus')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input
@@ -622,27 +644,16 @@
{data.live.show.status === 'ready' ? 'Als Entwurf markieren' : 'Als bereit markieren'}
</button>
</form>
<form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Sendung starten</button
>
</form>
{:else if data.live.endedAtUtc}
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
<p class="quiet-copy">Dieser Stream ist historisch abgeschlossen.</p>
{:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Lauf fortsetzen</button
><AppIcon name="play" /> Beendeten Lauf wiederaufnehmen</button
>
</form>
<form
@@ -670,71 +681,11 @@
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
<AppIcon name="box-archive" /> Stream abschließen
</button>
{/if}
{:else}
<form
method="POST"
action={liveAction(data.live.isPaused ? 'resume' : 'pause')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
{data.live.isPaused ? 'Fortsetzen' : 'Pausieren'}
</button>
</form>
{#if data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}<form
method="POST"
action={liveAction('resetSectionTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="arrow-rotate-left" /> Abschnittszeit zurücksetzen</button
>
</form>{/if}
<form
method="POST"
action={liveAction('resetTotalTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="timer" /> Gesamtzeit zurücksetzen</button
>
</form>
<form
method="POST"
action={liveAction('end')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Sendung wirklich beenden? Offene Pieces bleiben offen.'))
event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button danger-text" type="submit"
><AppIcon name="stop" /> Sendung beenden</button
>
</form>
{/if}
{#if needsResume && !data.live.endedAtUtc}<form
{#if needsResume}<form
method="POST"
action={liveAction('start')}
use:enhance={enhanceCommand}
@@ -742,12 +693,125 @@
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Live-Stand wiederaufnehmen</button>
<button class="secondary-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
{#if data.live.isPaused}<p class="live-menu-hint">
Fortsetzen ist direkt bei der Abschnittszeit erreichbar.
</p>{/if}
<form
method="POST"
action={liveAction('end')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Stream wirklich beenden? Offene Pieces bleiben offen.'))
event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button danger-text" type="submit"
><AppIcon name="stop" /> Stream beenden</button
>
</form>
{/if}
</section>
<section>
<strong>Ansicht</strong>
<div class="live-view-controls">
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
data-menu-initial-focus
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</div>
</section>
{#if data.canManageShares}<section>
<strong>Teilen</strong>
<ShareManagement
shows={[{ ...data.live.show, startsAtUtc: null }]}
shares={data.shares}
fixedShowId={data.live.show.id}
{form}
/>
</section>{/if}
<section class="live-output-section">
<details class="live-output-settings">
<summary>
<span><AppIcon name="display" /> Ausgabe einrichten</span>
<small
>{data.outputStatus === 'connected'
? 'Mit diesem Stream verbunden'
: data.outputStatus === 'other_show'
? 'Mit einem anderen Stream verbunden'
: 'Nicht verbunden'}</small
>
<AppIcon name="chevron-right" />
</summary>
<div class="live-output-settings-body">
<p>Der OBS-Ausgabekanal ändert sich erst durch eine der folgenden Aktionen.</p>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">
{data.outputStatus === 'connected'
? 'Ausgabe öffnen'
: 'Mit diesem Stream verbinden und öffnen'}
</button>
</form>
{#if data.outputStatus === 'connected'}
<form
method="POST"
action={liveAction('rotateOutput')}
use:enhance={enhanceOutput}
onsubmit={(event) => {
if (
!confirm(
'Schlüssel erneuern? Bereits eingerichtete OBS-Quellen verlieren den Zugriff.'
)
)
event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form
method="POST"
action={liveAction('clearOutput')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (
!confirm(
'Ausgabekanal trennen? Verbundene Browserquellen zeigen anschließend keinen Stream.'
)
)
event.preventDefault();
}}
>
<button class="text-button danger-text" type="submit">Kanal trennen</button>
</form>
{/if}
</div>
</details>
</section>
<nav class="live-menu-navigation" aria-label="Liveansicht verlassen">
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
><AppIcon name="arrow-left" /> Zur Planung</a
>
<small>Der Stream und die öffentliche Ausgabe laufen unverändert weiter.</small>
</nav>
</div>{/if}
<div class="live-messages">
@@ -761,7 +825,7 @@
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
<h2>Stream abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
@@ -774,7 +838,7 @@
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
<legend>Originale bewusst in den Piece-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
@@ -814,8 +878,13 @@
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}>
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')}
{#if viewedSection?.twitchTags.length}<button
class="text-button live-twitch-tags"
type="button"
onclick={copyTwitchTags}
>
<AppIcon name="copy" />
{viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
@@ -898,12 +967,12 @@
{sessionComplete
? 'Alle Pieces bearbeitet'
: data.live.totalCount
? 'Bereit für die Sendung'
? 'Bereit für den Stream'
: 'Noch kein Piece eingeplant'}
</h1>
<p>
{sessionComplete
? 'Der Live-Ablauf ist am Ende. Die Sendung wird hier nicht automatisch abgeschlossen.'
? 'Der Live-Ablauf ist am Ende. Der Stream wird hier nicht automatisch abgeschlossen.'
: data.live.totalCount
? 'Starte oder setze den gespeicherten Live-Stand fort.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
@@ -917,7 +986,7 @@
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Stream starten'}
</button>
</form>{/if}
</div>
@@ -1000,6 +1069,22 @@
>
</button>
<div class="live-run-actions">
{#if usage.status === 'done' || usage.status === 'skipped'}<form
method="POST"
action={liveAction('resumeUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="text-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}
>Wiederaufnehmen</button
>
</form>{/if}
{#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary
@@ -1176,6 +1261,19 @@
: 'Präsentieren'}
</button>
</form>
{#if (viewedUsage.status === 'done' || viewedUsage.status === 'skipped') && !viewingLiveCurrent}
<form method="POST" action={liveAction('resumeUsage')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="primary-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}>Wiederaufnehmen</button
>
</form>
{:else}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -1205,9 +1303,10 @@
? 'Erledigen & weiter'
: hasOtherOpenUsages
? 'Erledigen'
: 'Erledigen & Sendungsende'}
: 'Erledigen & Streamende'}
</button>
</form>
{/if}
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if}
</div>
+17 -4
View File
@@ -8,13 +8,26 @@ import {
validEmail
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
function safeReturnTo(value: string | null): string {
if (!value || value.length > 4000) return '/fundus';
try {
const target = new URL(value, 'http://sendewerk.local');
if (target.origin === 'http://sendewerk.local' && target.pathname === '/oauth/authorize') {
return `${target.pathname}${target.search}`;
}
} catch {
// Fall back to the normal authenticated landing page.
}
return '/fundus';
}
export const load: PageServerLoad = ({ locals, url }) => {
if (!hasUsers()) redirect(303, '/setup');
if (locals.user) redirect(303, '/fundus');
if (locals.user) redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
};
export const actions = {
default: async ({ request, cookies, getClientAddress }) => {
default: async ({ request, cookies, getClientAddress, url }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
@@ -31,6 +44,6 @@ export const actions = {
const user = await authenticate(email, password);
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
createSession(user.id, cookies);
redirect(303, '/fundus');
redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
}
} satisfies Actions;
+154
View File
@@ -0,0 +1,154 @@
import { json, text } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { handleMcpRequest, validJsonRpcRequest } from '#lib/server/mcp.js';
import { getAuthConfig } from '#lib/server/config.js';
import {
authenticateOAuth,
MCP_SCOPE,
OAuthError,
protectedResourceMetadataUrl
} from '#lib/server/oauth.js';
const MODERN_VERSION = '2026-07-28';
const supportedProtocolVersions = [MODERN_VERSION, '2025-11-25', '2025-06-18', '2025-03-26'];
function challenge(errorCode?: string) {
const fields = [`resource_metadata="${protectedResourceMetadataUrl()}"`, `scope="${MCP_SCOPE}"`];
if (errorCode) fields.push(`error="${errorCode}"`);
return `Bearer ${fields.join(', ')}`;
}
function authenticate(request: Request) {
try {
return authenticateOAuth(request);
} catch (cause) {
if (cause instanceof OAuthError) {
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
if (cause.status === 401) headers['WWW-Authenticate'] = challenge(cause.code);
return json(
{ error: cause.code, error_description: cause.message },
{ status: cause.status, headers }
);
}
throw cause;
}
}
function originAllowed(request: Request): boolean {
const origin = request.headers.get('origin');
return !origin || origin === getAuthConfig().origin.origin;
}
function protocolError(id: string | number | null, code: number, message: string, data?: unknown) {
return {
jsonrpc: '2.0',
id,
error: { code, message, ...(data === undefined ? {} : { data }) }
};
}
function modernValidation(
request: Request,
body: {
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
}
): Response | null {
const id = typeof body.id === 'string' || typeof body.id === 'number' ? body.id : null;
const metadata = body.params?._meta;
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
return json(protocolError(id, -32602, 'Required MCP request metadata is missing.'), {
status: 400
});
}
const meta = metadata as Record<string, unknown>;
const bodyVersion = meta['io.modelcontextprotocol/protocolVersion'];
if (typeof bodyVersion !== 'string' || !supportedProtocolVersions.includes(bodyVersion)) {
return json(
protocolError(id, -32022, 'Unsupported protocol version', {
supported: supportedProtocolVersions,
requested: bodyVersion ?? null
}),
{ status: 400 }
);
}
if (
request.headers.get('mcp-protocol-version') !== bodyVersion ||
request.headers.get('mcp-method') !== body.method ||
(body.method === 'tools/call' && request.headers.get('mcp-name') !== body.params?.name)
) {
return json(protocolError(id, -32020, 'MCP request headers do not match the body.'), {
status: 400
});
}
const capabilities = meta['io.modelcontextprotocol/clientCapabilities'];
if (
body.id === null ||
!capabilities ||
typeof capabilities !== 'object' ||
Array.isArray(capabilities)
) {
return json(protocolError(id, -32602, 'Required MCP request metadata is invalid.'), {
status: 400
});
}
return null;
}
export const POST: RequestHandler = async ({ request }) => {
if (!originAllowed(request)) return text('Forbidden', { status: 403 });
const authenticated = authenticate(request);
if (authenticated instanceof Response) return authenticated;
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
return text('JSON wird erwartet.', { status: 415 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
}
if (!validJsonRpcRequest(body)) {
return json({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } });
}
const metadata = body.params?._meta;
const bodyVersion =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)['io.modelcontextprotocol/protocolVersion']
: undefined;
const headerVersion = request.headers.get('mcp-protocol-version');
const modern = headerVersion === MODERN_VERSION || bodyVersion === MODERN_VERSION;
if (modern) {
const validationFailure = modernValidation(request, body);
if (validationFailure) return validationFailure;
} else if (headerVersion && !supportedProtocolVersions.includes(headerVersion)) {
return json(
protocolError(body.id ?? null, -32022, 'Unsupported protocol version', {
supported: supportedProtocolVersions,
requested: headerVersion
}),
{ status: 400 }
);
}
if (body.id === undefined) return new Response(null, { status: 202 });
const response = handleMcpRequest(body, authenticated);
const status = modern && 'error' in response && response.error?.code === -32601 ? 404 : 200;
return json(response, {
status,
headers: { 'Cache-Control': 'no-store' }
});
};
export const GET: RequestHandler = ({ request }) => {
if (!originAllowed(request)) return text('Forbidden', { status: 403 });
const authenticated = authenticate(request);
if (authenticated instanceof Response) return authenticated;
return text('Dieser MCP-Endpunkt bietet keinen serverseitigen Ereignisstrom.', {
status: 405,
headers: { Allow: 'POST' }
});
};
export const DELETE: RequestHandler = () =>
text('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } });
@@ -0,0 +1,53 @@
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
authorizationErrorRedirect,
authorizationRedirect,
createAuthorizationCode,
OAuthError,
parseAuthorizationRequest
} from '#lib/server/oauth.js';
function authorization(url: URL) {
try {
return parseAuthorizationRequest(url);
} catch (cause) {
if (cause instanceof OAuthError) {
const target = authorizationErrorRedirect(url, cause);
if (target) redirect(303, target, { external: true });
error(cause.status, cause.message);
}
throw cause;
}
}
export const load: PageServerLoad = ({ locals, url }) => {
const request = authorization(url);
if (!locals.user) {
const returnTo = `${url.pathname}${url.search}`;
redirect(303, `/login?returnTo=${encodeURIComponent(returnTo)}`);
}
return {
clientName: request.client.name,
redirectHost: new URL(request.redirectUri).host,
scope: request.scope,
user: locals.user
};
};
export const actions = {
default: ({ request: browserRequest, locals, url }) => {
if (!locals.user)
redirect(303, `/login?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
const oauthRequest = authorization(url);
return browserRequest.formData().then((data) => {
if (data.get('decision') !== 'approve') {
redirect(303, authorizationRedirect(oauthRequest, { error: 'access_denied' }), {
external: true
});
}
const code = createAuthorizationCode(oauthRequest, locals.user!);
redirect(303, authorizationRedirect(oauthRequest, { code }), { external: true });
});
}
} satisfies Actions;
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head><title>Zugriff erlauben | Sendewerk</title></svelte:head>
<section class="auth-card oauth-consent">
<p class="eyebrow">Externe Verbindung</p>
<h1>{data.clientName} verbinden?</h1>
<p>
Die Anwendung erhält im Namen von <strong>{data.user.displayName}</strong> lesenden Zugriff auf redaktionelle
Pieces, Streams und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und
die Reservebank.
</p>
<div class="oauth-permission">
<strong>Nur lesen</strong>
<span>Keine Inhalte anlegen, ändern, einplanen oder archivieren.</span>
</div>
<p class="oauth-redirect">
<strong>Nicht verifizierter OAuth-Client.</strong> Nach Zustimmung erfolgt der Rücksprung zu
<code>{data.redirectHost}</code>.
</p>
<form class="oauth-actions" method="POST">
<button class="secondary-button" type="submit" name="decision" value="deny">Ablehnen</button>
<button class="primary-button" type="submit" name="decision" value="approve">
Zugriff erlauben
</button>
</form>
</section>
+36
View File
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { OAuthError, registerOAuthClient, reserveOAuthAttempt } from '#lib/server/oauth.js';
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
try {
if (!reserveOAuthAttempt('registration', getClientAddress())) {
throw new OAuthError('temporarily_unavailable', 'Zu viele Registrierungsversuche.', 429);
}
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
throw new OAuthError('invalid_client_metadata', 'JSON wird erwartet.');
}
const client = registerOAuthClient(await request.json());
return json(
{
client_id: client.id,
client_id_issued_at: Math.floor(Date.now() / 1000),
client_name: client.name,
redirect_uris: client.redirectUris,
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'none'
},
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
);
} catch (cause) {
const problem =
cause instanceof OAuthError
? cause
: new OAuthError('invalid_client_metadata', 'Ungültige Client-Metadaten.');
return json(
{ error: problem.code, error_description: problem.message },
{ status: problem.status, headers: { 'Cache-Control': 'no-store' } }
);
}
};
+16
View File
@@ -0,0 +1,16 @@
import { text } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { revokeOAuthToken } from '#lib/server/oauth.js';
export const POST: RequestHandler = async ({ request }) => {
if (
request.headers
.get('content-type')
?.toLowerCase()
.startsWith('application/x-www-form-urlencoded')
) {
const data = await request.formData();
revokeOAuthToken(String(data.get('token') ?? ''), String(data.get('client_id') ?? ''));
}
return text('', { headers: { 'Cache-Control': 'no-store' } });
};
+57
View File
@@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
exchangeAuthorizationCode,
oauthTokenDurationSeconds,
OAuthError,
reserveOAuthAttempt
} from '#lib/server/oauth.js';
function failure(cause: unknown) {
const problem =
cause instanceof OAuthError
? cause
: new OAuthError('invalid_request', 'Ungültige Token-Anfrage.');
return json(
{ error: problem.code, error_description: problem.message },
{ status: problem.status, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
);
}
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
try {
if (!reserveOAuthAttempt('token', getClientAddress())) {
throw new OAuthError('temporarily_unavailable', 'Zu viele Token-Anfragen.', 429);
}
if (
!request.headers
.get('content-type')
?.toLowerCase()
.startsWith('application/x-www-form-urlencoded')
) {
throw new OAuthError('invalid_request', 'Formulardaten werden erwartet.');
}
const data = await request.formData();
if (data.get('grant_type') !== 'authorization_code') {
throw new OAuthError('unsupported_grant_type', 'Nur Authorization Code wird unterstützt.');
}
const result = exchangeAuthorizationCode({
code: String(data.get('code') ?? ''),
clientId: String(data.get('client_id') ?? ''),
redirectUri: String(data.get('redirect_uri') ?? ''),
codeVerifier: String(data.get('code_verifier') ?? ''),
resource: String(data.get('resource') ?? '')
});
return json(
{
access_token: result.accessToken,
token_type: 'Bearer',
expires_in: oauthTokenDurationSeconds(),
scope: result.scope
},
{ headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
);
} catch (cause) {
return failure(cause);
}
};
+34 -5
View File
@@ -2,8 +2,12 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
import { listPieces } from '#lib/server/content.js';
import {
createShow,
listPreparationUsages,
listShows,
listShowTemplates
} from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => {
@@ -11,9 +15,34 @@ export const load: PageServerLoad = ({ locals, url }) => {
const today = new Date();
const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
const query = (url.searchParams.get('q') ?? '').trim();
const state = url.searchParams.get('state') ?? '';
const allShows = listShows();
const searchedShows = query
? allShows.filter((show) =>
show.title.toLocaleLowerCase('de-DE').includes(query.toLocaleLowerCase('de-DE'))
)
: allShows;
const shows = searchedShows.filter((show) => {
if (state === 'created') return show.status === 'draft' && show.sectionCount === 0;
if (state === 'preparing') return show.status === 'draft' && show.sectionCount > 0;
if (state === 'ready') return show.status === 'ready' || show.status === 'live';
if (state === 'completed') return show.status === 'completed';
return true;
});
return {
shows: listShows(['draft', 'ready', 'live']),
preparationPieces: listPieces({ statuses: ['preparing'] }),
shows,
query,
state,
counts: {
all: allShows.length,
created: allShows.filter((show) => show.status === 'draft' && show.sectionCount === 0).length,
preparing: allShows.filter((show) => show.status === 'draft' && show.sectionCount > 0).length,
ready: allShows.filter((show) => show.status === 'ready' || show.status === 'live').length,
completed: allShows.filter((show) => show.status === 'completed').length
},
plannedShows: allShows.filter((show) => show.status !== 'completed').slice(0, 6),
preparationUsages: listPreparationUsages(),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
@@ -61,7 +90,7 @@ export const actions = {
});
}
return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.',
error: 'Der Stream konnte nicht angelegt werden.',
title,
startsAt,
templateId
+80 -25
View File
@@ -3,6 +3,8 @@
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { onMount } from 'svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
@@ -30,6 +32,11 @@
hour: '2-digit',
minute: '2-digit'
});
onMount(() => {
if (new URL(window.location.href).searchParams.get('create') === '1') {
document.querySelector<HTMLDetailsElement>('.show-create-panel')?.setAttribute('open', '');
}
});
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
@@ -53,21 +60,32 @@
}).format(new Date(`${value}T12:00:00Z`));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf';
function preparationHref(showId: string, usageId: string) {
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
}
function statusLabel(status: string, sectionCount = 1) {
if (status === 'ready') return 'Sendefertig';
if (status === 'draft') return sectionCount ? 'In Vorbereitung' : 'Angelegt';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<svelte:head><title>Streams | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page">
<div class="stream-workspace">
<StreamNavigation
query={data.query}
state={data.state}
counts={data.counts}
shows={data.plannedShows}
/>
<div class="page-wrap shows-page">
<header class="page-heading shows-heading">
<div>
<h1>Sendungen</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
<h1>Streams</h1>
<p class="page-intro">Streams aus Pieces und freien Abschnitten zusammenstellen.</p>
</div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} />
@@ -83,7 +101,7 @@
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<details class="show-create-panel">
<summary><AppIcon name="plus" /> Neue Sendung planen</summary>
<summary><AppIcon name="plus" /> Neuen Stream planen</summary>
<div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2>
@@ -95,7 +113,7 @@
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung"
placeholder="Nächster heinzlive-Stream"
/></label
>
<label
@@ -107,7 +125,7 @@
><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
name="newspaper"
/><span
><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span
><strong>Leer starten</strong><small>Einen leeren Stream frei aufbauen.</small></span
></label
>
{#each data.templates as template (template.id)}<label
@@ -121,23 +139,52 @@
></label
>{/each}
</fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button>
<button class="primary-button" type="submit">Stream anlegen</button>
</form>
</details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
{#if data.preparationUsages.length}<section
class="show-library preparation-overview"
aria-labelledby="preparation-title"
>
<header>
<h2 id="preparation-title">Noch vorzubereiten</h2>
<span>{data.preparationUsages.length}</span>
</header>
<div class="preparation-list">
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span>
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if}
</a>{/each}
{#each data.preparationUsages as usage (usage.usageId)}
<!-- The helper starts from a route resolved against the configured base path. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={preparationHref(usage.showId, usage.usageId)}>
<div class="preparation-copy">
<span
class="preparation-topics"
aria-label={usage.topics.map((topic) => topic.name).join(', ')}
>{#each usage.topics as topic (topic.name)}<AppIcon
name={topic.iconName}
/>{/each}</span
>
<span
><strong>{usage.title}</strong><small
>{usage.showTitle} · {usage.sectionName}</small
></span
>
</div>
{#if usage.preparationEffort}<b class={`effort ${usage.preparationEffort}`}
>{usage.preparationEffort === 'low'
? 'Gering'
: usage.preparationEffort === 'medium'
? 'Mittel'
: 'Hoch'}</b
>{/if}
</a>
{/each}
</div>
</section>{/if}
<section class="show-library" aria-labelledby="show-library-title">
<header>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
<h2 id="show-library-title">{data.view === 'list' ? 'Streams' : 'Kalender'}</h2>
<span>{data.shows.length}</span>
</header>
@@ -148,7 +195,9 @@
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<span class={`show-state ${show.status}`}
>{statusLabel(show.status, show.sectionCount)}</span
>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
@@ -160,8 +209,8 @@
</div>
</a>{/each}
</div>{:else}<div class="empty-state compact">
<h2>Keine terminierten Sendungen</h2>
<p>Sendungen ohne Termin bleiben unten erreichbar.</p>
<h2>Keine terminierten Streams</h2>
<p>Streams ohne Termin bleiben unten erreichbar.</p>
</div>{/if}
{:else}
<div class="show-month-toolbar">
@@ -176,7 +225,8 @@
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
>
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}
>Heute</a
>
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
@@ -207,7 +257,7 @@
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span>
<small>{statusLabel(show.status)}</small>
<small>{statusLabel(show.status, show.sectionCount)}</small>
</a>{/each}
</div>{/if}
</section>{/each}
@@ -227,7 +277,9 @@
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span
><strong>{show.title}</strong><small
>{statusLabel(show.status, show.sectionCount)}</small
></span
>
</a>{/each}
</div>
@@ -252,7 +304,9 @@
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<span class={`show-state ${show.status}`}
>{statusLabel(show.status, show.sectionCount)}</span
>
<h3>{show.title}</h3>
<p>Termin offen</p>
</div>
@@ -262,4 +316,5 @@
</a>{/each}
</div>
</section>{/if}
</div>
</div>
+51 -9
View File
@@ -4,7 +4,13 @@ import type { Actions, PageServerLoad } from './$types';
import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import {
addPieceUsage,
@@ -20,6 +26,7 @@ import {
removePieceUsage,
removeShowSection,
saveShowMetadata,
listShows,
updatePieceUsage,
updateShowSection,
type EditableShowStatus,
@@ -60,15 +67,15 @@ function actionError(cause: unknown) {
if (code === 'CONFLICT')
return {
status: 409,
error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.',
error: 'Der Stream wurde inzwischen geändert. Bitte neu laden.',
conflict: true
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' };
return { status: 409, error: 'Abgeschlossene Streams können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
return { status: 409, error: 'Das zugehörige Piece-Original ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
@@ -78,7 +85,7 @@ function actionError(cause: unknown) {
return {
status: 409,
error:
'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
'Diese Streamfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
};
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
@@ -104,8 +111,24 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, params, url }) => {
const actor = requireUser(locals.user);
const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden');
if (!show) error(404, 'Stream nicht gefunden');
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
const navigationShows = listShows();
const streamNavigation = {
query: '',
state: '',
counts: {
all: navigationShows.length,
created: navigationShows.filter((item) => item.status === 'draft' && item.sectionCount === 0)
.length,
preparing: navigationShows.filter((item) => item.status === 'draft' && item.sectionCount > 0)
.length,
ready: navigationShows.filter((item) => item.status === 'ready' || item.status === 'live')
.length,
completed: navigationShows.filter((item) => item.status === 'completed').length
},
shows: navigationShows.filter((item) => item.status !== 'completed').slice(0, 6)
};
if (show.status === 'completed') {
return {
show,
@@ -119,11 +142,12 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
taxonomy: getTaxonomy(),
pieceTemplates: [],
createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false }
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false },
streamNavigation
};
}
const pieces = listPieces({
statuses: ['ready'],
statuses: ['preparing', 'ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
});
@@ -148,7 +172,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
}
},
streamNavigation
};
};
@@ -192,6 +217,20 @@ export const actions = {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
}
},
replaceShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), params.id);
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'replaceShare', ...actionError(cause) });
}
},
deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
@@ -318,6 +357,9 @@ export const actions = {
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'),
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
preparationEffort: (text(data, 'preparationEffort') ||
null) as PieceUsageUpdate['preparationEffort'],
presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({
id,
+56 -31
View File
@@ -9,6 +9,7 @@
import AppIcon from '#lib/components/AppIcon.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -171,7 +172,7 @@
function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) {
if (!confirm('Piece aus diesem Stream entfernen?')) {
finishDrag();
return;
}
@@ -204,14 +205,16 @@
}
</script>
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head>
<svelte:head><title>{data.show.title} | Streams | Sendewerk</title></svelte:head>
{#if data.historical}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap historical-show-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a>
<p class="eyebrow">Unveränderliche Sendungshistorie</p>
<p class="eyebrow">Unveränderliche Streamhistorie</p>
<h1>{data.show.title}</h1>
<p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -224,7 +227,7 @@
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
<button class="secondary-button" type="submit">Als neuen Stream duplizieren</button>
</form>
</div>
</header>
@@ -300,11 +303,14 @@
</section>{/each}
</div>
</div>
</div>
{:else}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Streams</a
>
<h1>{data.show.title}</h1>
<p class="show-date">
@@ -337,7 +343,7 @@
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings">
<summary>Sendungsdaten und Twitch-Texte</summary>
<summary>Streamdaten und Twitch-Texte</summary>
<form
use:enhance={enhanceCommand}
method="POST"
@@ -385,7 +391,7 @@
>
<div class="wide-field settings-submit">
<span>{copyMessage}</span><button class="primary-button" type="submit"
>Sendungsdaten speichern</button
>Streamdaten speichern</button
>
</div>
</form>
@@ -402,7 +408,7 @@
class:active={mobilePane === 'fundus'}
aria-pressed={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
onclick={() => (mobilePane = 'fundus')}>Pieces</button
>
<button
class:active={mobilePane === 'plan'}
@@ -431,12 +437,12 @@
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
<h2 id="source-title">Planbare Pieces</h2>
</div>
<span>{visiblePieces.length}</span>
</header>
{#if dropTarget === 'source'}<div class="drag-remove-target">
<strong>Aus Sendung entfernen</strong><span>Das Fundus-Original bleibt erhalten.</span>
<strong>Aus Stream entfernen</strong><span>Das Piece-Original bleibt erhalten.</span>
</div>{/if}
<details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary>
@@ -464,11 +470,11 @@
</details>
<form id="builder-filters" method="GET" class="builder-filter">
<label
><span class="sr-only">Fundus durchsuchen</span><input
><span class="sr-only">Pieces durchsuchen</span><input
type="search"
name="q"
value={data.filters.query}
placeholder="Fundus durchsuchen"
placeholder="Pieces durchsuchen"
/></label
>
<label
@@ -482,7 +488,7 @@
>
<button class="text-button" type="submit">Filtern</button>
</form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern">
<div class="builder-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
@@ -523,7 +529,8 @@
>
<div>
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
><i></i>{piece.categories.map((item) => item.name).join(', ') ||
'Offen'}</span
><span>{formatClock(piece.durationSeconds)}</span>
</div>
<h3>
@@ -535,7 +542,8 @@
{piece.format} · {piece.topics.map((item) => item.name).join(', ') ||
'Ohne Thema'}
</p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span
>{/if}
<PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div>
@@ -569,8 +577,8 @@
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2>
<p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
markieren.
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Bereich Pieces
sendefertig markieren.
</p>
</div>{/if}
</section>
@@ -582,7 +590,7 @@
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<p class="eyebrow">Streamplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
@@ -603,7 +611,10 @@
animate:flip={{ duration: 180 }}
>
{#if dropPreview?.kind === 'section' && dropPreview.position === index}
<div class="drag-insert-preview section-insert-preview" data-drop-preview="section">
<div
class="drag-insert-preview section-insert-preview"
data-drop-preview="section"
>
<span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div>
{/if}
@@ -661,6 +672,7 @@
{#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)}
<div
id={`usage-${usage.id}`}
class="usage-drag-slot"
role="presentation"
ondragover={(event) => {
@@ -750,6 +762,7 @@
</form>
<UsageEditorDialog
{usage}
open={page.url.searchParams.get('usage') === usage.id}
revision={data.show.revision}
sectionId={section.id}
sections={data.show.sections}
@@ -789,7 +802,7 @@
dropAtPreview();
}}
>
Leerer Abschnitt · Piece aus dem Fundus hinzufügen oder hierher ziehen
Leerer Abschnitt · Piece hinzufügen oder hierher ziehen
</div>
{/if}
<details class="section-editor">
@@ -842,7 +855,8 @@
method="POST"
action="?/removeSection"
onsubmit={(event) =>
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
!confirm('Abschnitt samt Einplanungen entfernen?') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
@@ -876,12 +890,17 @@
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
>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 data.taxonomy.categories as category (category.id)}<option
value={category.id}>{category.name}</option
>{/each}</select
></label
><label
@@ -893,7 +912,11 @@
placeholder="optional, Sekunden"
/></label
><label
>Twitch-Tags<input name="twitchTags" maxlength="300" placeholder="Gaming, Deutsch" /></label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
placeholder="Gaming, Deutsch"
/></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form>
@@ -953,7 +976,8 @@
ondragstart={(event) => startDrag('usage', usage.id, event)}
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small>
<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
@@ -965,8 +989,8 @@
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 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>
@@ -976,7 +1000,7 @@
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
@@ -985,8 +1009,8 @@
/><button
class="text-button danger-text"
type="submit"
aria-label={`${usage.title} aus Sendung entfernen`}
>Aus Sendung entfernen</button
aria-label={`${usage.title} aus Stream entfernen`}
>Aus Stream entfernen</button
>
</form>
<UsageEditorDialog
@@ -1066,4 +1090,5 @@
<input type="hidden" name="usageId" value={removedUsageId} />
</form>
</div>
</div>
{/if}
+9
View File
@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { searchWorkspace } from '#lib/server/global-search.js';
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const query = (url.searchParams.get('q') ?? '').trim().slice(0, 200);
return { query, results: searchWorkspace(query) };
};
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
let { data } = $props();
const statusLabels: Record<string, string> = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Bereit',
archived: 'Archiviert',
draft: 'Angelegt',
live: 'Live',
completed: 'Abgeschlossen'
};
</script>
<svelte:head><title>Suche | Sendewerk</title></svelte:head>
<section class="page-wrap narrow-page search-page">
<header class="page-heading">
<p class="eyebrow">Arbeitsbereich</p>
<h1>Suche</h1>
<p>Durchsuche Pieces und Streams einschließlich abgeschlossener und archivierter Inhalte.</p>
</header>
<form class="search-page-form" method="GET" action={resolve('/suche')} role="search">
<label>
<span class="sr-only">Suchbegriff</span>
<AppIcon name="magnifying-glass" />
<input
name="q"
type="search"
value={data.query}
minlength="2"
placeholder="Titel, Recherche, Ablauf …"
/>
</label>
<button class="primary-button" type="submit">Suchen</button>
</form>
{#if data.query.length === 1}
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
{:else if data.query}
<div class="search-result-columns">
<section aria-labelledby="piece-results">
<header class="result-group-heading">
<h2 id="piece-results">Pieces</h2>
<span>{data.results.pieces.length}</span>
</header>
{#if data.results.pieces.length}
<div class="search-result-list">
{#each data.results.pieces as result (result.id)}
<!-- Search results contain only server-generated internal paths. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={result.href}>
<span class="result-title"
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
>{statusLabels[result.status] ?? result.status}</span
></span
>
<small>{result.matchLabel}</small>
<p>{result.excerpt}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine passenden Pieces.</p>{/if}
</section>
<section aria-labelledby="show-results">
<header class="result-group-heading">
<h2 id="show-results">Streams</h2>
<span>{data.results.shows.length}</span>
</header>
{#if data.results.shows.length}
<div class="search-result-list">
{#each data.results.shows as result (result.id)}
<!-- Search results contain only server-generated internal paths. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={result.href}>
<span class="result-title"
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
>{statusLabels[result.status] ?? result.status}</span
></span
>
<small>{result.matchLabel}</small>
<p>{result.excerpt}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine passenden Streams.</p>{/if}
</section>
</div>
{/if}
</section>
+120 -1
View File
@@ -8,10 +8,23 @@ import {
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#lib/server/feeds.js';
import {
createTechnicalAccess,
listTechnicalAccesses,
revokeTechnicalAccess,
updateTechnicalAccess
} from '#lib/server/technical-access.js';
import {
inviteUser,
isRole,
@@ -33,6 +46,8 @@ export const load: PageServerLoad = ({ locals }) => {
tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
feeds: listFeedSources(),
technicalAccesses: listTechnicalAccesses(),
mailTransport: getMailConfig().transport
};
};
@@ -59,6 +74,8 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
if (code === 'INVALID_FEED') return 'Name, URL und Abrufintervall bitte prüfen.';
if (code === 'INVALID_ACCESS') return 'Name und Berechtigung bitte prüfen.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
@@ -108,6 +125,20 @@ export const actions = {
return fail(400, { intent: 'updateShare', error: actionError(cause) });
}
},
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, String(data.get('shareId') || ''));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(400, { intent: 'replaceShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
@@ -249,5 +280,93 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
},
saveFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
saveFeedSource(String(data.get('feedId') || '') || null, {
name: String(data.get('name') || ''),
url: String(data.get('url') || ''),
isEnabled: data.get('isEnabled') === '1',
refreshIntervalMinutes: Number(data.get('refreshIntervalMinutes')),
defaultCategoryId: String(data.get('defaultCategoryId') || '') || null
});
return { intent: 'feed', success: 'Feed gespeichert.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
deleteFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteFeedSource(String(data.get('feedId') || ''));
return { intent: 'feed', success: 'Feed entfernt.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
fetchFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
const result = await fetchFeed(String(data.get('feedId') || ''));
return {
intent: 'feed',
success: result.unchanged
? 'Feed ist unverändert.'
: `${result.inserted} neue Beiträge abgerufen.`
};
} catch {
return fail(400, {
intent: 'feed',
error: 'Feed konnte nicht sicher abgerufen oder gelesen werden.'
});
}
},
createTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const created = createTechnicalAccess(
actor,
String(data.get('name') || ''),
String(data.get('permissions') || '')
);
return {
intent: 'technicalAccess',
success:
'Technischer Zugang angelegt. Token jetzt sicher hinterlegen; es wird nicht erneut angezeigt.',
technicalToken: created.token
};
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
},
updateTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
updateTechnicalAccess(
actor,
String(data.get('accessId') || ''),
String(data.get('name') || ''),
String(data.get('permissions') || '')
);
return { intent: 'technicalAccess', success: 'Technischer Zugang aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
},
revokeTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
revokeTechnicalAccess(actor, String(data.get('accessId') || ''));
return { intent: 'technicalAccess', success: 'Technischer Zugang widerrufen.' };
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
}
} satisfies Actions;
+149 -5
View File
@@ -1,11 +1,19 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import FeedEditorDialog from '#lib/components/FeedEditorDialog.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import TechnicalAccessDialog from '#lib/components/TechnicalAccessDialog.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
let feedDialog: { showModal: (feed?: (typeof data.feeds)[number] | null) => void };
let technicalAccessDialog: {
showModal: (access?: (typeof data.technicalAccesses)[number] | null) => void;
};
let managementIntent = $derived(form?.intent?.toLowerCase() ?? '');
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
@@ -19,19 +27,144 @@
<h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.error && !['share', 'technicalaccess'].some( (intent) => managementIntent.includes(intent) )}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success && !['share', 'technicalaccess'].some( (intent) => managementIntent.includes(intent) )}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-catalog">
<div class="panel-heading">
<div>
<p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2>
</div>
<button class="compact-add-button" type="button" onclick={() => feedDialog.showModal()}>
<AppIcon name="plus" /> Feed hinzufügen
</button>
</div>
<p class="catalog-note">
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang.
</p>
<div class="catalog-settings-list integration-settings-list">
{#each data.feeds as feed (feed.id)}
<article class:inactive={!feed.isEnabled} class="catalog-setting-row">
<button
class="catalog-entry-main"
type="button"
aria-label={`${feed.name} bearbeiten`}
onclick={() => feedDialog.showModal(feed)}
>
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
<span class="catalog-entry-copy">
<strong>{feed.name}</strong>
<small
>{feed.lastError
? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc
? `Zuletzt ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'} · {feed.isEnabled ? 'Automatisch' : 'Pausiert'}</small
>
</span>
<AppIcon name="pen" />
</button>
<div class="catalog-entry-actions">
<form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} />
<button type="submit" aria-label={`${feed.name} jetzt abrufen`} title="Jetzt abrufen"
><AppIcon name="arrow-rotate-right" /></button
>
</form>
<form
method="POST"
action="?/deleteFeed"
onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
>
<input type="hidden" name="feedId" value={feed.id} />
<button
class="danger-text"
type="submit"
aria-label={`${feed.name} löschen`}
title="Feed löschen"><AppIcon name="trash" /></button
>
</form>
</div>
</article>
{/each}
{#if !data.feeds.length}<p class="empty-copy">Noch keine RSS-Feeds eingerichtet.</p>{/if}
</div>
</section>
<FeedEditorDialog bind:this={feedDialog} categories={data.taxonomy.categories} />
<section class="quiet-panel integration-catalog">
<div class="panel-heading">
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2>
</div>
<button
class="compact-add-button"
type="button"
onclick={() => technicalAccessDialog.showModal()}
>
<AppIcon name="plus" /> Zugang hinzufügen
</button>
</div>
<p class="catalog-note">
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein. <a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI öffnen</a>
</p>
<div class="catalog-settings-list integration-settings-list">
{#each data.technicalAccesses as access (access.id)}
<article class:inactive={Boolean(access.revokedAtUtc)} class="catalog-setting-row">
<button
class="catalog-entry-main"
type="button"
disabled={Boolean(access.revokedAtUtc)}
aria-label={`${access.name} bearbeiten`}
onclick={() => technicalAccessDialog.showModal(access)}
>
<span class="catalog-entry-icon"><AppIcon name="key" /></span>
<span class="catalog-entry-copy">
<strong>{access.name}</strong>
<small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
>
</span>
{#if !access.revokedAtUtc}<AppIcon name="pen" />{/if}
</button>
{#if !access.revokedAtUtc}<div class="catalog-entry-actions">
<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} />
<button
class="danger-text"
type="submit"
aria-label={`${access.name} widerrufen`}
title="Zugang widerrufen"><AppIcon name="ban" /></button
>
</form>
</div>{/if}
</article>
{/each}
{#if !data.technicalAccesses.length}<p class="empty-copy">
Noch keine technischen Zugänge angelegt.
</p>{/if}
</div>
</section>
<TechnicalAccessDialog bind:this={technicalAccessDialog} {form} />
<section class="quiet-panel account-panel">
<div class="panel-heading">
@@ -129,7 +262,8 @@
</section>
</div>
<h2 class="section-heading">Werkzeuge</h2>
<h2 class="section-heading">Werkzeuge & Freigaben</h2>
<ShareManagement shows={data.shows} shares={data.shares} {form} catalogPanel />
<section class="quiet-panel tool-management">
<div class="panel-heading">
<div>
@@ -141,7 +275,7 @@
</button>
</div>
<p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Streams erhalten.
</p>
<div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)}
@@ -272,3 +406,13 @@
</section>
</div>
</div>
<style>
.integration-catalog {
margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
}
.integration-catalog .catalog-note a {
white-space: nowrap;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
{
"name": "Sendewerk",
"short_name": "Sendewerk",
"description": "Redaktion, Sendungsplanung und Live-Regie für heinzlive",
"start_url": "/sendungen",
"display": "standalone",
"background_color": "#0b1220",
"theme_color": "#0b1220",
"lang": "de",
"orientation": "any"
}
+459 -104
View File
File diff suppressed because it is too large. Load diff