3 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
74 changed files with 6690 additions and 937 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. # Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN= SENDEWERK_SETUP_TOKEN=
# Public production origins must use HTTPS; loopback HTTP remains available locally.
SENDEWERK_APP_ORIGIN=http://localhost:5173 SENDEWERK_APP_ORIGIN=http://localhost:5173
# "local" writes messages to the outbox and never contacts a mail server. # "local" writes messages to the outbox and never contacts a mail server.
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.7.1", "version": "0.8.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+60 -4
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen Etappe 1 bis 7: technisches Grundgerüst, Anmeldung und Rollen, Fundus,
persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine Sendungsbau, Live-Steuerung, Präsentationsausgabe, Abschluss, Archiv,
abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6. Freigaben, RSS sowie technische REST- und MCP-Zugänge.
## Voraussetzungen ## Voraussetzungen
@@ -62,6 +62,9 @@ enthält bewusst kein Konto und kein Standardpasswort.
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`. Cloudron liegen Datenbank und Uploads unter `/app/data`.
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 Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand 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 Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
werden nicht übernommen. 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 ## Befehle
| Befehl | Zweck | | 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 HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben. 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, Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden. abschließend geprüft werden.
@@ -226,7 +282,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3`, `@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 Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
installierten Baum. installierten Baum.
+80
View File
@@ -1,5 +1,85 @@
# Abnahme # 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 ## Wartungsrelease 0.7.1
| Bereich | Nachweis | Stand | | Bereich | Nachweis | Stand |
+111 -16
View File
@@ -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 wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die
spätere API dieselben Regeln verwenden. 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 ## Laufzeit und Speicher
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar. - Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
@@ -380,12 +415,12 @@ Status und Termin.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an Auf dem Desktop liegt sie als feste horizontale Glasleiste oberhalb der
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und Arbeitsfläche. Bis einschließlich `52rem` ersetzen eine kompakte obere Leiste
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten und die untere Hauptnavigation diesen Desktopkopf vollständig. Auf breiten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem`
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. schmale Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare, Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
@@ -422,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 gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog
nicht in ihren Client-Chunk. 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 Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung, Arbeitsblatt ohne Desktopdialog an. Titel, Einleitung, Moderationsstichpunkte,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche. Interaktion, Überleitung, private Recherche, Quellen und Werkzeuge bilden die
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten mittlere Schreibfläche. Status, Dauer, Einordnung, Präsentationsmodi und Medien
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen liegen auf breiten Ansichten rechts. Alle Werte werden weiterhin gemeinsam über
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht die bestehende `save`-Action und Piece-Revision gespeichert; separate Bild- und
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und Präsentationsactions bleiben eigene serverseitige Mutationen.
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die `RichMarkdownField` erstellt je sichtbarem Langtext genau eine Tiptap-Instanz mit
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige StarterKit und offizieller Markdown-Erweiterung. Der Formularwert bleibt rohes
Mutationen. 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 ## Sicherheitsgrenzen
@@ -465,3 +511,52 @@ Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben
Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und
alle bisherigen Gastsitzungen werden in derselben Transaktion ungültig. Der neue alle bisherigen Gastsitzungen werden in derselben Transaktion ungültig. Der neue
Klartexttoken wird weiterhin nur in der Action-Antwort ausgegeben. 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.
+102
View File
@@ -518,3 +518,105 @@ Duplikation und ausdrückliches Aktualisieren kopieren den Zustand, während der
Usage-Editor ihn danach unabhängig ändert. `piece_usages.status` bleibt davon Usage-Editor ihn danach unabhängig ändert. `piece_usages.status` bleibt davon
getrennt und beschreibt weiterhin ausschließlich den Livezustand `open`, getrennt und beschreibt weiterhin ausschließlich den Livezustand `open`,
`current`, `done` oder `skipped`. `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.
+107
View File
@@ -1,5 +1,112 @@
# Release Notes # 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 ## 0.7.1 - Bedienung und Live-Korrekturen
Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026 Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026
+109
View File
@@ -8,6 +8,115 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert. 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 ## Fehlerkorrekturen 0.7.1
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen - Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
+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);
+567 -2
View File
@@ -1,14 +1,18 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.1", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.1", "version": "0.8.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@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", "better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2", "fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0", "file-type": "^22.2.0",
@@ -1234,6 +1238,404 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "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": { "node_modules/@tokenizer/inflate": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
@@ -3578,6 +3980,12 @@
"node": ">=10" "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": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -3986,6 +4394,12 @@
"node": ">= 0.8.0" "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": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4400,6 +4814,145 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4513,6 +5066,12 @@
"@rolldown/binding-win32-x64-msvc": "1.2.14" "@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": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -5276,6 +5835,12 @@
"node": ">=12.20.0" "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": { "node_modules/w3c-xmlserializer": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+5 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.7.1", "version": "0.8.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -25,6 +25,10 @@
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@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", "better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2", "fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0", "file-type": "^22.2.0",
+1554 -2
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -8,6 +8,7 @@ declare global {
interface PageData { interface PageData {
user?: AuthUser | null; user?: AuthUser | null;
presentationMode?: boolean; presentationMode?: boolean;
oauthMode?: boolean;
} }
} }
} }
+12 -1
View File
@@ -20,6 +20,10 @@ export const handle: Handle = async ({ event, resolve }) => {
const sharePath = event.url.pathname.startsWith('/freigabe/'); const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname); const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const apiPath = event.url.pathname.startsWith('/api/v1/'); 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 presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
@@ -30,6 +34,8 @@ export const handle: Handle = async ({ event, resolve }) => {
!sharePath && !sharePath &&
!publicPaths.has(event.url.pathname) && !publicPaths.has(event.url.pathname) &&
!apiPath && !apiPath &&
!oauthPath &&
!mcpPath &&
!event.locals.user !event.locals.user
) { ) {
redirect(303, hasUsers() ? '/login' : '/setup'); redirect(303, hasUsers() ? '/login' : '/setup');
@@ -66,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'); response.headers.set('Cache-Control', 'no-store');
} }
return response; 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>
@@ -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>
+34 -14
View File
@@ -6,6 +6,8 @@
labelledby, labelledby,
activeSection = $bindable(), activeSection = $bindable(),
onrequestclose, onrequestclose,
inline = false,
sidePanel = false,
header, header,
children, children,
footer footer
@@ -13,12 +15,14 @@
labelledby: string; labelledby: string;
activeSection: 'script' | 'research' | 'presentation'; activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void; onrequestclose: () => void;
inline?: boolean;
sidePanel?: boolean;
header: Snippet; header: Snippet;
children: Snippet; children: Snippet;
footer: Snippet; footer: Snippet;
} = $props(); } = $props();
let dialog: HTMLDialogElement; let dialog = $state<HTMLDialogElement>();
let workspace: HTMLDivElement; let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 }; const scrollPositions = { script: 0, research: 0, presentation: 0 };
@@ -30,24 +34,18 @@
} }
export function showModal() { 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() { export function close() {
dialog.close(); if (!inline) dialog?.close();
} }
</script> </script>
<dialog {#snippet editorChrome()}
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
<header class="piece-dialog-header"> <header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div> <div class="piece-dialog-heading">{@render header()}</div>
<button <button
@@ -57,12 +55,13 @@
onclick={onrequestclose}><AppIcon name="xmark" /></button onclick={onrequestclose}><AppIcon name="xmark" /></button
> >
</header> </header>
{#if !inline}
<nav class="piece-dialog-tabs" aria-label="Editorbereiche"> <nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button <button
type="button" type="button"
class:active={activeSection === 'script'} class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined} aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button onclick={() => selectSection('script')}>Streamtext</button
> >
<button <button
type="button" type="button"
@@ -77,6 +76,27 @@
onclick={() => selectSection('presentation')}>Präsentation</button onclick={() => selectSection('presentation')}>Präsentation</button
> >
</nav> </nav>
{/if}
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div> <div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer> <footer class="piece-dialog-footer">{@render footer()}</footer>
{/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> </dialog>
{/if}
+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>
+5 -5
View File
@@ -123,7 +123,7 @@
</button> </button>
</div> </div>
<p class="catalog-note"> <p class="catalog-note">
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen. Streams gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
</p> </p>
</section> </section>
{:else} {:else}
@@ -153,7 +153,7 @@
</button> </button>
</div> </div>
<p class="share-intro"> <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. wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p> </p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div {#if form?.intent?.toLowerCase().includes('share') && form.error}<div
@@ -187,8 +187,8 @@
<input type="hidden" name="showId" value={fixedShowId} /> <input type="hidden" name="showId" value={fixedShowId} />
{:else} {:else}
<label <label
>Sendung<select name="showId" required> >Stream<select name="showId" required>
<option value="">Sendung wählen</option> <option value="">Stream wählen</option>
{#each shows as show (show.id)}<option value={show.id} {#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option >{show.title} · {statusLabel(show.status)}</option
>{/each} >{/each}
@@ -317,7 +317,7 @@
</details> </details>
{/each} {/each}
{#if !shares.some((share) => share.showId === show.id)} {#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} {/if}
</div> </div>
</article> </article>
+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>
+18 -17
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte'; import RichMarkdownField from './RichMarkdownField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte'; import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte';
@@ -130,9 +130,10 @@
bind:activeSection bind:activeSection
labelledby={`usage-editor-title-${usage.id}`} labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose} onrequestclose={requestClose}
sidePanel
> >
{#snippet header()} {#snippet header()}
<p class="eyebrow">Sendungsfassung</p> <p class="eyebrow">Streamfassung</p>
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} /> <InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2> <h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet} {/snippet}
@@ -153,31 +154,31 @@
<div class="piece-panel-heading"> <div class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Für die Moderation</p> <p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3> <h3>Streamtext</h3>
</div> </div>
</div> </div>
<div class="piece-writing-stack"> <div class="piece-writing-stack">
<MarkdownEditorField <RichMarkdownField
name="intro" name="intro"
label="Einleitung" label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?" placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro} bind:value={draft.intro}
/> />
<MarkdownEditorField <RichMarkdownField
name="moderation" name="moderation"
label="Moderationsstichpunkte" label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte" placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10} rows={10}
bind:value={draft.moderation} bind:value={draft.moderation}
/> />
<MarkdownEditorField <RichMarkdownField
name="interaction" name="interaction"
label="Interaktion" label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?" placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField privateField
bind:value={draft.interaction} bind:value={draft.interaction}
/> />
<MarkdownEditorField <RichMarkdownField
name="transition" name="transition"
label="Überleitung" label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?" placeholder="Wie geht es zum nächsten Thema weiter?"
@@ -230,7 +231,7 @@
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small> <small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div> </div>
<div class="piece-writing-stack"> <div class="piece-writing-stack">
<MarkdownEditorField <RichMarkdownField
name="research" name="research"
label="Private Recherche" label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen" placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -238,10 +239,10 @@
privateField privateField
bind:value={draft.research} bind:value={draft.research}
/> />
<MarkdownEditorField <RichMarkdownField
name="ownNotes" name="ownNotes"
label="Interne Notizen zu dieser Verwendung" 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 privateField
bind:value={draft.ownNotes} bind:value={draft.ownNotes}
/> />
@@ -260,7 +261,7 @@
<p class="eyebrow">Snapshot</p> <p class="eyebrow">Snapshot</p>
<h3>Werkzeuge</h3> <h3>Werkzeuge</h3>
</div> </div>
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small> <small>Name und Start-URL bleiben Teil der Streamfassung.</small>
</div> </div>
{#if draft.tools.length}<div class="source-list"> {#if draft.tools.length}<div class="source-list">
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row"> {#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
@@ -280,7 +281,7 @@
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label <label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
> >
</div>{/each} </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>
</section> </section>
@@ -316,7 +317,7 @@
bind:value={draft.outputTitle} bind:value={draft.outputTitle}
/></label /></label
> >
<MarkdownEditorField <RichMarkdownField
name="presentation" name="presentation"
label="Öffentlicher Vorspann" label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe" placeholder="Text für die Publikumsausgabe"
@@ -438,7 +439,7 @@
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty} <button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button >Aus Piece aktualisieren</button
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
@@ -470,7 +471,7 @@
method="POST" method="POST"
action="?/removeUsage" action="?/removeUsage"
onsubmit={(event) => 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 <input type="hidden" name="revision" value={revision} /><input
type="hidden" type="hidden"
@@ -478,7 +479,7 @@
value={usage.id} value={usage.id}
/> />
<button class="text-button danger-text" type="submit" disabled={dirty} <button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button >Aus Stream entfernen</button
> >
</form> </form>
</div> </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>
+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' 'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
) )
.run(timestamp, row.user_id); .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('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(); reset();
} }
@@ -592,7 +598,15 @@ export function setUserLocked(actor: AuthUser, userId: string, locked: boolean):
database database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?') .prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.run(locked ? nowUtc() : null, nowUtc(), userId); .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(); update();
} }
+4
View File
@@ -18,6 +18,10 @@ export function getAuthConfig() {
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.'); throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
} }
const origin = new URL(configuredOrigin || 'http://localhost:5173'); 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 { return {
origin, origin,
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '', setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 19, migrations: 20,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+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 };
}
+5 -1
View File
@@ -7,8 +7,9 @@ import { closeDatabase, getDatabase } from './database.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel, clearPresentationChannel,
getPresentationChannelView,
getLiveView, getLiveView,
getPresentationChannelStatus,
getPresentationChannelView,
getPresentationMedia, getPresentationMedia,
getPresentationView, getPresentationView,
goBack, goBack,
@@ -159,7 +160,9 @@ describe('live mode', () => {
}); });
it('keeps one stable output channel until its key is explicitly rotated', () => { it('keeps one stable output channel until its key is explicitly rotated', () => {
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
const key = issuePresentationAccess('show-october'); const key = issuePresentationAccess('show-october');
expect(getPresentationChannelStatus('show-october')).toBe('connected');
expect(issuePresentationAccess('show-october')).toBe(key); expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true); expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({ expect(getPresentationChannelView('default')).toEqual({
@@ -175,6 +178,7 @@ describe('live mode', () => {
presentUsage('show-october', 'usage-console', 'present-channel', 1); presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull(); expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel(); clearPresentationChannel();
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
expect(getPresentationChannelView('default')?.content).toBeNull(); expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october'); issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull(); expect(getPresentationChannelView('default')?.content).toBeNull();
+8
View File
@@ -1158,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) { function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null; if (!token || token.length > 200) return null;
const channel = database const channel = database
+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;
}
+1 -1
View File
@@ -181,7 +181,7 @@ describe('show builder data layer', () => {
const blank = createShow({ title: null, startsAtUtc: null, templateId: null }); const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
expect(getShow(blank.id)).toMatchObject({ expect(getShow(blank.id)).toMatchObject({
title: 'Neue Sendung', title: 'Neuer Stream',
startsAtUtc: null, startsAtUtc: null,
sections: [], sections: [],
reserve: [] reserve: []
+1 -1
View File
@@ -1021,7 +1021,7 @@ export function getPieceUsageContexts(
export function createShow(input: CreateShowInput): CreatedMutationResult { export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase(); const database = getDatabase();
const id = randomUUID(); 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 startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString(); const now = new Date().toISOString();
const create = database.transaction(() => { const create = database.transaction(() => {
+7 -1
View File
@@ -9,7 +9,8 @@ import {
authenticateTechnical, authenticateTechnical,
createTechnicalAccess, createTechnicalAccess,
idempotentResponse, idempotentResponse,
revokeTechnicalAccess revokeTechnicalAccess,
updateTechnicalAccess
} from './technical-access.js'; } from './technical-access.js';
let directory: string; let directory: string;
@@ -64,6 +65,11 @@ describe('technical API access', () => {
.prepare('SELECT token_hash FROM technical_accesses WHERE id = ?') .prepare('SELECT token_hash FROM technical_accesses WHERE id = ?')
.get(created.id) .get(created.id)
).not.toMatchObject({ token_hash: created.token }); ).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); revokeTechnicalAccess(actor, created.id);
expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED'); expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED');
}); });
+25
View File
@@ -48,6 +48,31 @@ export function createTechnicalAccess(actor: AuthUser, nameValue: string, permis
return { id, token }; 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) { export function revokeTechnicalAccess(actor: AuthUser, id: string) {
const result = getDatabase() const result = getDatabase()
.prepare( .prepare(
+1
View File
@@ -7,5 +7,6 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({
: locals.user, : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'), presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'), guestMode: url.pathname.startsWith('/freigabe/'),
oauthMode: url.pathname === '/oauth/authorize',
liveControllerMode: url.pathname === '/live' liveControllerMode: url.pathname === '/live'
}); });
+38 -39
View File
@@ -5,22 +5,21 @@
import NavIcon from '#lib/components/NavIcon.svelte'; import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import UserMenu from '#lib/components/UserMenu.svelte';
let { children, data } = $props(); let { children, data } = $props();
const primaryNavigation = [ const primaryNavigation = [
{ href: '/', label: 'Start', icon: 'house' },
{ href: '/eingang', label: 'Eingang', icon: 'inbox' }, { href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' }, { href: '/fundus', label: 'Pieces', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' }, { href: '/sendungen', label: 'Streams', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' } { href: '/live', label: 'Live', icon: 'tower-broadcast' }
] as const; ] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) { function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
return ( return (
page.url.pathname.startsWith(href) || (href === '/sendungen' && page.url.pathname === '/live') page.url.pathname.startsWith(href) || (href === '/fundus' && page.url.pathname === '/archiv')
); );
} }
</script> </script>
@@ -28,13 +27,13 @@
<svelte:head> <svelte:head>
<meta <meta
name="description" name="description"
content="Sendewerk organisiert redaktionelle Inhalte und heinzlive-Sendungen." content="Sendewerk organisiert redaktionelle Pieces und heinzlive-Streams."
/> />
</svelte:head> </svelte:head>
{#if data.presentationMode} {#if data.presentationMode}
{@render children()} {@render children()}
{:else if data.guestMode} {:else if data.guestMode || data.oauthMode}
<div class="guest-shell"> <div class="guest-shell">
<header class="auth-header"> <header class="auth-header">
<span class="brand" <span class="brand"
@@ -49,8 +48,8 @@
<main id="main-content" class="live-controller-shell">{@render children()}</main> <main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user} {:else if data.user}
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <header class="app-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 class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span> <span>Sendewerk</span>
</a> </a>
@@ -68,47 +67,47 @@
{/each} {/each}
</nav> </nav>
<div class="sidebar-footer"> <div class="app-header-actions">
{#if data.user.role === 'barkeeper'} <form class="global-search-form" method="GET" action={resolve('/suche')} role="search">
<nav class="utility-navigation" aria-label="Verwaltung"> <label>
<a <AppIcon name="magnifying-glass" />
href={resolve(administration.href)} <span class="sr-only">Pieces und Streams durchsuchen</span>
class:active={isActive(administration.href)} <input
aria-current={isActive(administration.href) ? 'page' : undefined} name="q"
> type="search"
<NavIcon name={administration.icon} /> value={page.url.pathname === '/suche' ? (page.url.searchParams.get('q') ?? '') : ''}
<span>{administration.label}</span> placeholder="Pieces und Streams suchen"
</a> />
</nav> </label>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
</form> </form>
<ThemeToggle /> <UserMenu displayName={data.user.displayName} role={data.user.role} />
</div> </div>
</aside> </header>
<header class="mobile-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 class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span> <span>Sendewerk</span>
</a> </a>
<form method="POST" action="/logout"> <form
<button class="mobile-logout" type="submit" aria-label="Abmelden" class="global-search-form mobile-global-search"
><AppIcon name="right-from-bracket" /></button 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> </form>
<ThemeToggle /> <UserMenu displayName={data.user.displayName} role={data.user.role} />
</header> </header>
<main id="main-content">{@render children()}</main> <main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation"> <nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)} {#each primaryNavigation as item (item.href)}
<a <a
href={resolve(item.href)} href={resolve(item.href)}
class:active={isActive(item.href)} class:active={isActive(item.href)}
+32 -1
View File
@@ -1,7 +1,38 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js'; 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 }) { export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup'); 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' } }
);
+3 -3
View File
@@ -76,15 +76,15 @@ export const GET: RequestHandler = () =>
} }
}, },
'/shows': { '/shows': {
get: { summary: 'Sendungen lesen', responses: ok }, get: { summary: 'Streams lesen', responses: ok },
post: { post: {
summary: 'Sendung anlegen', summary: 'Stream anlegen',
parameters: [idempotency], parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }), requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
responses: { '201': { description: 'Angelegt' } } responses: { '201': { description: 'Angelegt' } }
} }
}, },
'/shows/{id}': { get: { summary: 'Sendung lesen', parameters: [id], responses: ok } }, '/shows/{id}': { get: { summary: 'Stream lesen', parameters: [id], responses: ok } },
'/shows/{id}/usages': { '/shows/{id}/usages': {
post: { post: {
summary: 'Piece einplanen', summary: 'Piece einplanen',
+1 -1
View File
@@ -50,7 +50,7 @@ export const actions = {
redirect(303, `/sendungen/${duplicate.id}`); redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) { } catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw 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; } satisfies Actions;
+6 -25
View File
@@ -1,41 +1,22 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head> <svelte:head><title>Archivierte Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Archiv</h1> <p class="eyebrow">Pieces</p>
<p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p> <h1>Archiviert und gelöscht</h1>
<p class="page-intro">Bewusst abgelegte Pieces bleiben auffindbar und wiederherstellbar.</p>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if 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> <h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list"> {#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> <div>
<h2>{piece.title}</h2> <h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p> <p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
@@ -57,7 +38,7 @@
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title"> <section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2> <h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy"> <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> </p>
{#if data.deletedPieces.length}<div class="archive-list"> {#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article> {#each data.deletedPieces as piece (piece.id)}<article>
+16 -4
View File
@@ -46,22 +46,34 @@ export const actions = {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String); const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' }); if (!entryIds.length)
return fail(400, {
intent: 'feedImport',
feedError: 'Mindestens einen Beitrag auswählen.'
});
try { try {
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String)); const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
return { return {
intent: 'feedImport',
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.` feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
}; };
} catch { } catch {
return fail(400, { feedError: 'Die Auswahl konnte nicht übernommen werden.' }); return fail(400, {
intent: 'feedImport',
feedError: 'Die Auswahl konnte nicht übernommen werden.'
});
} }
}, },
discardFeeds: async ({ locals, request }) => { discardFeeds: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String); const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' }); if (!entryIds.length)
return fail(400, { intent: 'feedDiscard', feedError: 'Mindestens einen Beitrag auswählen.' });
const count = discardFeedEntries(entryIds); const count = discardFeedEntries(entryIds);
return { feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` }; return {
intent: 'feedDiscard',
feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.`
};
} }
} satisfies Actions; } satisfies Actions;
+31 -97
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let rssDialog: { showModal: () => void };
</script> </script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head> <svelte:head><title>Eingang | Sendewerk</title></svelte:head>
@@ -44,65 +45,39 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button> <button class="primary-button" type="submit">Im Eingang ablegen</button>
</form> </form>
</section> </section>
<section class="quiet-panel feed-inbox"> <section class="quiet-panel rss-inbox-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<p class="eyebrow">RSS-Importeingang</p> <p class="eyebrow">RSS-Importeingang</p>
<h2>Beiträge sichten</h2> <h2>RSS-Beiträge</h2>
</div> </div>
<span>{data.feedEntries.length} neu</span> <span class="catalog-entry-icon"><AppIcon name="rss" /></span>
</div> </div>
<p>Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.</p> <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?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status"> {#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess} {form.feedSuccess}
</div>{/if} </div>{/if}
{#if data.feedEntries.length} <button
<form method="POST" class="feed-selection"> class="compact-add-button"
<fieldset class="feed-categories"> type="button"
<legend>Kategorien für die Auswahl <span>(Feed-Standard gilt ohne Auswahl)</span></legend> disabled={!data.feedEntries.length}
{#each data.taxonomy.categories as category (category.id)} onclick={() => rssDialog.showModal()}
<label
><input type="checkbox" name="categoryIds" value={category.id} />
{category.name}</label
> >
{/each} <AppIcon name="list" />
</fieldset> {data.feedEntries.length ? 'RSS-Beiträge auswählen' : 'Keine neuen RSS-Beiträge'}
<div class="feed-entry-list"> </button>
{#each data.feedEntries as entry (entry.id)}
<label class="feed-entry">
<input type="checkbox" name="entryIds" value={entry.id} />
<span
><small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
><strong>{entry.title}</strong>{#if entry.summary}<span>{entry.summary}</span>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}>Original öffnen</a
></span
>
</label>
{/each}
</div>
<div class="feed-actions">
<button class="primary-button" type="submit" formaction="?/importFeeds"
>Auswahl übernehmen</button
>
<button class="text-button danger-text" type="submit" formaction="?/discardFeeds"
>Auswahl verwerfen</button
>
</div>
</form>
{:else}
<p class="empty-copy">
Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen.
</p>
{/if}
</section> </section>
<RssSelectionDialog
bind:this={rssDialog}
entries={data.feedEntries}
categories={data.taxonomy.categories}
{form}
/>
{#if data.pieces.length} {#if data.pieces.length}
<section class="inbox-list"> <section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2> <h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
@@ -143,57 +118,16 @@
</div> </div>
<style> <style>
.feed-inbox { .rss-inbox-panel {
display: grid; display: grid;
gap: 1rem; gap: 0.75rem;
margin-block: 1.5rem; margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
} }
.feed-selection, .rss-inbox-panel .panel-heading {
.feed-entry-list { margin: 0;
display: grid;
gap: 0.75rem;
} }
.feed-categories { .rss-inbox-panel .catalog-note {
display: flex; margin: 0;
flex-wrap: wrap;
gap: 0.6rem 1rem;
border: 0;
padding: 0;
}
.feed-categories legend {
width: 100%;
font-weight: 700;
margin-bottom: 0.45rem;
}
.feed-categories legend span {
font-weight: 400;
color: var(--color-text-muted);
}
.feed-entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
align-items: start;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 0.7rem;
cursor: pointer;
}
.feed-entry > span {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.feed-entry small {
color: var(--color-text-muted);
}
.feed-entry a {
width: fit-content;
}
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
} }
</style> </style>
+3 -3
View File
@@ -15,7 +15,7 @@
} }
</script> </script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head> <svelte:head><title>Streamfreigabe | Sendewerk</title></svelte:head>
<main class="share-page"> <main class="share-page">
{#if data.status === 'unavailable'} {#if data.status === 'unavailable'}
@@ -38,13 +38,13 @@
autocomplete="current-password" autocomplete="current-password"
/></label /></label
> >
<button class="primary-button" type="submit">Sendung öffnen</button> <button class="primary-button" type="submit">Stream öffnen</button>
</form> </form>
</section> </section>
{:else} {:else}
<header class="share-heading"> <header class="share-heading">
<p class="eyebrow"> <p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen {data.share.isCompleted ? 'Abgeschlossener Stream' : 'Aktuelle Streamplanung'} · Nur lesen
</p> </p>
<h1>{data.share.title}</h1> <h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p> <p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
+8 -34
View File
@@ -5,44 +5,18 @@ import {
createPiece, createPiece,
deletePieceTemplate, deletePieceTemplate,
duplicatePiece, duplicatePiece,
getTaxonomy,
listPieces,
listPieceTemplates, listPieceTemplates,
setPieceArchived, setPieceArchived,
setPieceDeleted, setPieceDeleted,
setPieceFavorite setPieceFavorite
} from '#lib/server/content.js'; } 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 }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); 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 { return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), ...loadFundusWorkspace(url),
taxonomy: getTaxonomy(), templates: listPieceTemplates()
templates: listPieceTemplates(),
filters
}; };
}; };
@@ -54,7 +28,7 @@ function actionError(cause: unknown) {
} }
export const actions = { export const actions = {
create: async ({ locals, request }) => { create: async ({ locals, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string; let id: string;
@@ -63,7 +37,7 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) }); return fail(400, { intent: 'create', error: actionError(cause) });
} }
redirect(303, `/fundus/${id}`); redirect(303, `/fundus/${id}${fundusSearch(url)}`);
}, },
deleteTemplate: async ({ locals, request }) => { deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -75,7 +49,7 @@ export const actions = {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) }); return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
} }
}, },
duplicate: async ({ locals, request }) => { duplicate: async ({ locals, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string; let id: string;
@@ -84,7 +58,7 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) }); return fail(400, { intent: 'duplicate', error: actionError(cause) });
} }
redirect(303, `/fundus/${id}`); redirect(303, `/fundus/${id}${fundusSearch(url)}`);
}, },
archive: async ({ locals, request }) => { archive: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -113,7 +87,7 @@ export const actions = {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true); setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { return {
intent: 'delete', intent: 'delete',
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.' success: 'Piece gelöscht. Bestehende Streamfassungen bleiben erhalten.'
}; };
} catch (cause) { } catch (cause) {
return fail(409, { intent: 'delete', error: actionError(cause) }); return fail(409, { intent: 'delete', error: actionError(cause) });
+64 -30
View File
@@ -4,8 +4,10 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import { formatClock } from '#lib/format.js'; import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -15,8 +17,8 @@
let visiblePieces = $derived( let visiblePieces = $derived(
data.pieces.filter( data.pieces.filter(
(piece) => (piece) =>
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) && (data.filters.view === 'completed' || showUsedPieces || piece.sentHistory.length === 0) &&
(showPlannedPieces || !piece.isPlannedInActiveShow) (data.filters.view === 'completed' || showPlannedPieces || !piece.isPlannedInActiveShow)
) )
); );
onMount(() => { onMount(() => {
@@ -26,9 +28,35 @@
showPlannedPieces = search.get('includePlanned') === '1'; showPlannedPieces = search.get('includePlanned') === '1';
}; };
window.addEventListener('popstate', syncFiltersFromHistory); 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); 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) { function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked; if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked; else showPlannedPieces = checked;
@@ -53,13 +81,16 @@
}; };
</script> </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"> <header class="page-heading split-heading">
<div> <div>
<h1>Fundus</h1> <p class="eyebrow">Redaktionelle Bibliothek</p>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <h1>Pieces</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Streams.</p>
</div> </div>
<div class="fundus-heading-actions"> <div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}> <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?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<form <form id="library-filters" class="library-filters" method="GET" aria-label="Pieces filtern">
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<div class="fundus-search-row"> <div class="fundus-search-row">
<label class="search-field" <input type="hidden" name="q" value={data.filters.query} />
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input {#if data.filters.view}<input type="hidden" name="view" value={data.filters.view} />{/if}
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker"> <details class="filter-picker">
<summary><AppIcon name="list-check" /> Weitere Filter</summary> <summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel"> <div class="filter-panel">
@@ -136,7 +155,8 @@
> >
<label <label
><span>Format</span><select name="format" ><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} value={item.id}
selected={data.filters.formatId === item.id}>{item.name}</option selected={data.filters.formatId === item.id}>{item.name}</option
>{/each}</select >{/each}</select
@@ -159,7 +179,7 @@
>{/each}</select >{/each}</select
></label ></label
> >
<div class="library-filter-options" aria-label="Fundusauswahl erweitern"> <div class="library-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input <input
type="checkbox" type="checkbox"
@@ -176,7 +196,8 @@
name="includePlanned" name="includePlanned"
value="1" value="1"
checked={showPlannedPieces} checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)} onchange={(event) =>
setSourceFilter('includePlanned', event.currentTarget.checked)}
/> />
<span>Auch eingeplante Inhalte zeigen</span> <span>Auch eingeplante Inhalte zeigen</span>
</label> </label>
@@ -197,6 +218,12 @@
class="piece-card" class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`} 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> <form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
<input type="hidden" name="pieceId" value={piece.id} /> <input type="hidden" name="pieceId" value={piece.id} />
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} /> <input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
@@ -214,11 +241,16 @@
class="piece-thumbnail" class="piece-thumbnail"
src={`/media/${piece.mediaId}`} src={`/media/${piece.mediaId}`}
alt="" alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"> />{/if}
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
</span>{/if}
<div class="piece-primary"> <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} {#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
</div> </div>
@@ -244,7 +276,8 @@
> >
</div> </div>
<details class="piece-actions"> <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"> <div class="piece-actions-popover">
<form method="POST" action="?/duplicate"> <form method="POST" action="?/duplicate">
@@ -265,7 +298,7 @@
action="?/delete" action="?/delete"
onsubmit={(event) => onsubmit={(event) =>
!confirm( !confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.` `Piece „${piece.title}“ löschen? Bestehende Streamfassungen bleiben erhalten.`
) && event.preventDefault()} ) && event.preventDefault()}
> >
<input type="hidden" name="pieceId" value={piece.id} /><input <input type="hidden" name="pieceId" value={piece.id} /><input
@@ -288,4 +321,5 @@
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a> <a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div> </div>
{/if} {/if}
</section>
</div> </div>
+11 -9
View File
@@ -19,6 +19,7 @@ import {
type PieceSourceInput type PieceSourceInput
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js'; import { renderMarkdown } from '#lib/server/markdown.js';
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
import { listTools } from '#lib/server/tools.js'; import { listTools } from '#lib/server/tools.js';
function renderedFields( 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); requireUser(locals.user);
const piece = getPiece(params.id); const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden'); if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv'); if (piece.status === 'archived') redirect(303, '/archiv');
return { return {
workspace: loadFundusWorkspace(url),
piece, piece,
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
tools: listTools(true), tools: listTools(true),
@@ -165,7 +167,7 @@ export const actions = {
return fail(problem.status, { intent: 'save', ...problem }); return fail(problem.status, { intent: 'save', ...problem });
} }
}, },
loadPreview: async ({ locals, params, request }) => { loadPreview: async ({ locals, params, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
try { try {
@@ -188,7 +190,7 @@ export const actions = {
const problem = saveError(cause); const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem }); return fail(problem.status, { intent: 'loadPreview', ...problem });
} }
redirect(303, `/fundus/${params.id}`); redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
}, },
saveTemplate: async ({ locals, params, request }) => { saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -209,7 +211,7 @@ export const actions = {
return fail(problem.status, { intent: 'saveTemplate', ...problem }); return fail(problem.status, { intent: 'saveTemplate', ...problem });
} }
}, },
upload: async ({ locals, params, request }) => { upload: async ({ locals, params, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
const image = data.get('image'); 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: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' }); 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); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
try { try {
@@ -249,9 +251,9 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error }); 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); requireUser(locals.user);
let id: string; let id: string;
try { try {
@@ -259,7 +261,7 @@ export const actions = {
} catch { } catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' }); 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 }) => { archive: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
+46 -24
View File
@@ -2,10 +2,12 @@
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onDestroy, onMount, untrack } from 'svelte'; 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 AppIcon from '#lib/components/AppIcon.svelte';
import ChoicePicker from '#lib/components/ChoicePicker.svelte'; import ChoicePicker from '#lib/components/ChoicePicker.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte'; import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte'; import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte'; import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
@@ -139,10 +141,12 @@
} }
function requestClose() { function requestClose() {
const target = resolve('/fundus'); const target = `${resolve('/fundus')}${page.url.search}`;
if (saveState === 'error' || saveState === 'conflict') { if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return; if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
allowNavigation = true; 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); void goto(target);
return; return;
} }
@@ -152,6 +156,8 @@
return; return;
} }
allowNavigation = true; 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); void goto(target);
} }
@@ -246,8 +252,14 @@
}; };
</script> </script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head> <svelte:head><title>{draft.title} | Pieces | Sendewerk</title></svelte:head>
<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"> <div class="piece-editor-route">
{#if form?.error}<div class="form-message error route-editor-error" role="alert"> {#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error} {form.error}
@@ -257,17 +269,20 @@
bind:activeSection bind:activeSection
labelledby="piece-editor-title" labelledby="piece-editor-title"
onrequestclose={requestClose} onrequestclose={requestClose}
inline
> >
{#snippet header()} {#snippet header()}
<div class="piece-editor-kicker"> <div class="piece-editor-kicker">
<p class="eyebrow">Fundusfassung</p> <p class="eyebrow">Piece-Fassung</p>
<form method="POST" action="?/favorite" use:enhance> <form method="POST" action="?/favorite" use:enhance>
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} /> <input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
<button <button
class="piece-favorite" class="piece-favorite"
class:active={data.piece.isFavorite} class:active={data.piece.isFavorite}
type="submit" 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 aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
> >
</form> </form>
@@ -292,42 +307,42 @@
> >
<input type="hidden" name="revision" value={revision} /> <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 class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Für die Moderation</p> <p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2> <h2>Streamtext</h2>
</div> </div>
</div> </div>
<div class="piece-writing-stack"> <div class="piece-writing-stack">
<MarkdownEditorField <RichMarkdownField
name="intro" name="intro"
label="Einleitung" label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?" placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro} bind:value={draft.intro}
/> />
<MarkdownEditorField <RichMarkdownField
name="moderationNotes" name="moderationNotes"
label="Moderationsstichpunkte" label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte" placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10} rows={10}
bind:value={draft.moderationNotes} bind:value={draft.moderationNotes}
/> />
<MarkdownEditorField <RichMarkdownField
name="interactionText" name="interactionText"
label="Interaktion" label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?" placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField privateField
bind:value={draft.interactionText} bind:value={draft.interactionText}
/> />
<MarkdownEditorField <RichMarkdownField
name="transitionText" name="transitionText"
label="Überleitung" label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?" placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText} bind:value={draft.transitionText}
/> />
</div> </div>
<details class="piece-meta-details"> <details class="piece-meta-details" open>
<summary> <summary>
<span>Status, Dauer und Einordnung</span> <span>Status, Dauer und Einordnung</span>
<small <small
@@ -405,7 +420,7 @@
</details> </details>
</section> </section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}> <section class="piece-editor-panel piece-research-panel">
<div class="piece-panel-heading"> <div class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Nicht öffentlich</p> <p class="eyebrow">Nicht öffentlich</p>
@@ -413,7 +428,7 @@
</div> </div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small> <small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div> </div>
<MarkdownEditorField <RichMarkdownField
name="researchText" name="researchText"
label="Private Recherche" label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen" placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -524,7 +539,7 @@
</section> </section>
</section> </section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}> <section class="piece-editor-panel piece-presentation-panel">
<div class="piece-panel-heading"> <div class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Öffentliche Ausgabe</p> <p class="eyebrow">Öffentliche Ausgabe</p>
@@ -559,7 +574,7 @@
bind:value={draft.outputTitle} bind:value={draft.outputTitle}
/></label /></label
> >
<MarkdownEditorField <RichMarkdownField
name="presentationText" name="presentationText"
label="Öffentlicher Vorspann" label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe" placeholder="Text für die Publikumsausgabe"
@@ -617,7 +632,8 @@
maxlength="1000" maxlength="1000"
placeholder="Kurzer Text zur Vorschau" placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription} bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label oninput={() => (draft.presentationDescriptionManual = true)}
></textarea></label
> >
<div class="presentation-preview-actions wide-field"> <div class="presentation-preview-actions wide-field">
<button <button
@@ -645,7 +661,11 @@
name="presentationDescription" name="presentationDescription"
value={draft.presentationDescription} value={draft.presentationDescription}
/> />
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if} {/if}
</div> </div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
@@ -684,8 +704,7 @@
<section <section
class="piece-editor-panel piece-media-panel" class="piece-editor-panel piece-media-panel"
hidden={activeSection !== 'presentation' || hidden={!['text', 'image'].includes(draft.presentationMode)}
!['text', 'image'].includes(draft.presentationMode)}
> >
<div class="piece-subsection-heading"> <div class="piece-subsection-heading">
<div> <div>
@@ -727,8 +746,10 @@
type="hidden" type="hidden"
name="mediaId" name="mediaId"
value={media.id} value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked} /><button
>Entfernen</button class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Entfernen</button
> >
</form> </form>
</figcaption> </figcaption>
@@ -793,7 +814,7 @@
method="POST" method="POST"
action="?/delete" action="?/delete"
onsubmit={(event) => onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') && !confirm('Piece löschen? Bestehende Streamfassungen bleiben erhalten.') &&
event.preventDefault()} event.preventDefault()}
> >
<input type="hidden" name="revision" value={revision} /><button <input type="hidden" name="revision" value={revision} /><button
@@ -839,3 +860,4 @@
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
</form> </form>
</div> </div>
</div>
+10 -8
View File
@@ -8,6 +8,7 @@ import {
clearPresentationChannel, clearPresentationChannel,
endLive, endLive,
getLiveView, getLiveView,
getPresentationChannelStatus,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
@@ -48,7 +49,7 @@ function actionError(cause: unknown) {
}; };
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' }; if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NOT_STARTED') 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 === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED') if (code === 'LIVE_ORDER_LOCKED')
return { return {
@@ -56,16 +57,16 @@ function actionError(cause: unknown) {
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.' error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
}; };
if (code === 'SHOW_COMPLETED') 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') if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' }; return { status: 404, error: 'Stream oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' }; if (code === 'TIMER_PAUSED') return { status: 409, error: 'Der Stream ist pausiert.' };
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING') if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' }; return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
if (code === 'NO_SECTION_TIMER') if (code === 'NO_SECTION_TIMER')
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' }; return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED') 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.' }; 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.' }; return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
} }
@@ -82,11 +83,12 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
const actor = requireUser(locals.user); const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id; const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden'); if (!showId) error(404, 'Kein Stream vorhanden');
try { try {
const live = getLiveView(showId); const live = getLiveView(showId);
return { return {
live, live,
outputStatus: getPresentationChannelStatus(showId),
canManageShares: actor.role === 'barkeeper', canManageShares: actor.role === 'barkeeper',
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [], shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
commandId: randomUUID(), commandId: randomUUID(),
@@ -95,7 +97,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
}; };
} catch (cause) { } catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND') if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden'); error(404, 'Stream nicht gefunden');
throw cause; throw cause;
} }
}; };
@@ -208,7 +210,7 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
const status = text(data, 'status'); const status = text(data, 'status');
if (status !== 'draft' && status !== 'ready') { 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', () => return attempt('setStatus', () =>
setShowStatus( setShowStatus(
+193 -134
View File
@@ -74,10 +74,10 @@
) )
); );
let currentSectionTitle = $derived( let currentSectionTitle = $derived(
currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie') currentSection?.name ?? (sessionComplete ? 'Streamende' : 'Live-Regie')
); );
let nextTitle = $derived( 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( let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) + (data.live.current?.actualDurationSeconds ?? 0) +
@@ -300,9 +300,10 @@
menuOpen = true; menuOpen = true;
expandedContext = null; expandedContext = null;
await tick(); await tick();
liveMenuPopover (
?.querySelector<HTMLElement>('button:not(:disabled), a[href]') liveMenuPopover?.querySelector<HTMLElement>('[data-menu-initial-focus]') ??
?.focus({ preventScroll: true }); liveMenuPopover?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
)?.focus({ preventScroll: true });
} }
async function closeLiveMenu(restoreFocus = true) { async function closeLiveMenu(restoreFocus = true) {
@@ -361,6 +362,15 @@
return 'Kommend'; 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) { function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`; return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
} }
@@ -481,6 +491,7 @@
</form>{/if} </form>{/if}
</div> </div>
<div class="total-timer-control">
<button <button
class="total-countdown" class="total-countdown"
type="button" type="button"
@@ -503,6 +514,23 @@
> >
{/if} {/if}
</button> </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 class="section-timer-control">
<div <div
@@ -534,12 +562,30 @@
class:paused={data.live.isPaused} class:paused={data.live.isPaused}
class="live-timer-toggle" class="live-timer-toggle"
type="submit" type="submit"
aria-label={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'} aria-label={data.live.isPaused ? 'Stream fortsetzen' : 'Stream pausieren'}
title={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'} title={data.live.isPaused ? 'Stream fortsetzen' : 'Stream pausieren'}
> >
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} /> <AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
</button> </button>
</form>{/if} </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> </div>
<button <button
@@ -567,52 +613,23 @@
inert={mobileRailOpen} inert={mobileRailOpen}
> >
<section class="live-menu-show"> <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> <strong>{data.live.show.title}</strong>
</section> </span>
<section> <span class:paused={data.live.isPaused} class="live-show-state">{showStateLabel()}</span>
<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
>
</div> </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} {#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}> <form method="POST" action={liveAction('setStatus')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input <input
@@ -627,27 +644,16 @@
{data.live.show.status === 'ready' ? 'Als Entwurf markieren' : 'Als bereit markieren'} {data.live.show.status === 'ready' ? 'Als Entwurf markieren' : 'Als bereit markieren'}
</button> </button>
</form> </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} {:else if data.live.endedAtUtc}
{#if data.live.show.status === 'completed'} {#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p> <p class="quiet-copy">Dieser Stream ist historisch abgeschlossen.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
{:else} {:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit" <button class="secondary-button" type="submit"
><AppIcon name="play" /> Lauf fortsetzen</button ><AppIcon name="play" /> Beendeten Lauf wiederaufnehmen</button
> >
</form> </form>
<form <form
@@ -675,71 +681,11 @@
type="button" type="button"
onclick={() => completionDialog?.showModal()} onclick={() => completionDialog?.showModal()}
> >
<AppIcon name="box-archive" /> Sendung abschließen <AppIcon name="box-archive" /> Stream abschließen
</button> </button>
{/if} {/if}
{:else} {:else}
<form {#if needsResume}<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
method="POST" method="POST"
action={liveAction('start')} action={liveAction('start')}
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
@@ -747,12 +693,125 @@
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <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} </form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })} {#if data.live.isPaused}<p class="live-menu-hint">
>Liveansicht verlassen · Zur Planung</a 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>
<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>{/if}
<div class="live-messages"> <div class="live-messages">
@@ -766,7 +825,7 @@
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header> <header>
<p class="eyebrow">Unveränderliche Historie</p> <p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2> <h2>Stream abschließen</h2>
</header> </header>
<p> <p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
@@ -779,7 +838,7 @@
><span><strong>{data.completion.openCount}</strong> offen</span> ><span><strong>{data.completion.openCount}</strong> offen</span>
</div> </div>
<fieldset> <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 {#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row" class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span ><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
@@ -908,12 +967,12 @@
{sessionComplete {sessionComplete
? 'Alle Pieces bearbeitet' ? 'Alle Pieces bearbeitet'
: data.live.totalCount : data.live.totalCount
? 'Bereit für die Sendung' ? 'Bereit für den Stream'
: 'Noch kein Piece eingeplant'} : 'Noch kein Piece eingeplant'}
</h1> </h1>
<p> <p>
{sessionComplete {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 : data.live.totalCount
? 'Starte oder setze den gespeicherten Live-Stand fort.' ? 'Starte oder setze den gespeicherten Live-Stand fort.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'} : 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
@@ -927,7 +986,7 @@
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit"> <button class="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'} {data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Stream starten'}
</button> </button>
</form>{/if} </form>{/if}
</div> </div>
@@ -1244,7 +1303,7 @@
? 'Erledigen & weiter' ? 'Erledigen & weiter'
: hasOtherOpenUsages : hasOtherOpenUsages
? 'Erledigen' ? 'Erledigen'
: 'Erledigen & Sendungsende'} : 'Erledigen & Streamende'}
</button> </button>
</form> </form>
{/if} {/if}
+17 -4
View File
@@ -8,13 +8,26 @@ import {
validEmail validEmail
} from '#lib/server/auth.js'; } 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 (!hasUsers()) redirect(303, '/setup');
if (locals.user) redirect(303, '/fundus'); if (locals.user) redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
}; };
export const actions = { export const actions = {
default: async ({ request, cookies, getClientAddress }) => { default: async ({ request, cookies, getClientAddress, url }) => {
const data = await request.formData(); const data = await request.formData();
const email = String(data.get('email') || '').trim(); const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || ''); const password = String(data.get('password') || '');
@@ -31,6 +44,6 @@ export const actions = {
const user = await authenticate(email, password); const user = await authenticate(email, password);
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' }); if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
createSession(user.id, cookies); createSession(user.id, cookies);
redirect(303, '/fundus'); redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
} }
} satisfies Actions; } 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);
}
};
+27 -2
View File
@@ -15,8 +15,33 @@ export const load: PageServerLoad = ({ locals, url }) => {
const today = new Date(); const today = new Date();
const todayMonth = berlinMonthKey(today); const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth); 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 { return {
shows: listShows(['draft', 'ready', 'live']), 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(), preparationUsages: listPreparationUsages(),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
@@ -65,7 +90,7 @@ export const actions = {
}); });
} }
return fail(400, { return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.', error: 'Der Stream konnte nicht angelegt werden.',
title, title,
startsAt, startsAt,
templateId templateId
+42 -19
View File
@@ -3,6 +3,8 @@
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js'; import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js'; import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { onMount } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -30,6 +32,11 @@
hour: '2-digit', hour: '2-digit',
minute: '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) { function berlinDate(value: string | null) {
if (!value) return 'Termin offen'; if (!value) return 'Termin offen';
@@ -57,21 +64,28 @@
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`; return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
} }
function statusLabel(status: string) { function statusLabel(status: string, sectionCount = 1) {
if (status === 'ready') return 'Bereit'; if (status === 'ready') return 'Sendefertig';
if (status === 'draft') return 'Entwurf'; if (status === 'draft') return sectionCount ? 'In Vorbereitung' : 'Angelegt';
if (status === 'live') return 'Live'; if (status === 'live') return 'Live';
return 'Abgeschlossen'; return 'Abgeschlossen';
} }
</script> </script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>Streams | Sendewerk</title></svelte:head>
<div class="stream-workspace">
<StreamNavigation
query={data.query}
state={data.state}
counts={data.counts}
shows={data.plannedShows}
/>
<div class="page-wrap shows-page"> <div class="page-wrap shows-page">
<header class="page-heading shows-heading"> <header class="page-heading shows-heading">
<div> <div>
<h1>Sendungen</h1> <h1>Streams</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p> <p class="page-intro">Streams aus Pieces und freien Abschnitten zusammenstellen.</p>
</div> </div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung"> <form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} /> <input type="hidden" name="month" value={data.selectedMonth} />
@@ -87,7 +101,7 @@
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<details class="show-create-panel"> <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"> <div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p> <p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2> <h2>Leer starten oder Ablauf übernehmen</h2>
@@ -99,7 +113,7 @@
required required
maxlength="200" maxlength="200"
value={form?.title ?? ''} value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung" placeholder="Nächster heinzlive-Stream"
/></label /></label
> >
<label <label
@@ -111,7 +125,7 @@
><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon ><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
name="newspaper" name="newspaper"
/><span /><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 ></label
> >
{#each data.templates as template (template.id)}<label {#each data.templates as template (template.id)}<label
@@ -125,7 +139,7 @@
></label ></label
>{/each} >{/each}
</fieldset> </fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button> <button class="primary-button" type="submit">Stream anlegen</button>
</form> </form>
</details> </details>
@@ -151,7 +165,8 @@
/>{/each}</span />{/each}</span
> >
<span <span
><strong>{usage.title}</strong><small>{usage.showTitle} · {usage.sectionName}</small ><strong>{usage.title}</strong><small
>{usage.showTitle} · {usage.sectionName}</small
></span ></span
> >
</div> </div>
@@ -169,7 +184,7 @@
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <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> <span>{data.shows.length}</span>
</header> </header>
@@ -180,7 +195,9 @@
href={resolve('/sendungen/[id]', { id: show.id })} href={resolve('/sendungen/[id]', { id: show.id })}
> >
<div> <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> <h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p> <p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div> </div>
@@ -192,8 +209,8 @@
</div> </div>
</a>{/each} </a>{/each}
</div>{:else}<div class="empty-state compact"> </div>{:else}<div class="empty-state compact">
<h2>Keine terminierten Sendungen</h2> <h2>Keine terminierten Streams</h2>
<p>Sendungen ohne Termin bleiben unten erreichbar.</p> <p>Streams ohne Termin bleiben unten erreichbar.</p>
</div>{/if} </div>{/if}
{:else} {:else}
<div class="show-month-toolbar"> <div class="show-month-toolbar">
@@ -208,7 +225,8 @@
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`} href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a 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 <a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`} href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
@@ -239,7 +257,7 @@
> >
<time>{berlinTime(show.startsAtUtc!)}</time> <time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span> <span>{show.title}</span>
<small>{statusLabel(show.status)}</small> <small>{statusLabel(show.status, show.sectionCount)}</small>
</a>{/each} </a>{/each}
</div>{/if} </div>{/if}
</section>{/each} </section>{/each}
@@ -259,7 +277,9 @@
> >
<time>{berlinTime(show.startsAtUtc!)}</time> <time>{berlinTime(show.startsAtUtc!)}</time>
<span <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} </a>{/each}
</div> </div>
@@ -284,7 +304,9 @@
href={resolve('/sendungen/[id]', { id: show.id })} href={resolve('/sendungen/[id]', { id: show.id })}
> >
<div> <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> <h3>{show.title}</h3>
<p>Termin offen</p> <p>Termin offen</p>
</div> </div>
@@ -295,3 +317,4 @@
</div> </div>
</section>{/if} </section>{/if}
</div> </div>
</div>
+26 -7
View File
@@ -26,6 +26,7 @@ import {
removePieceUsage, removePieceUsage,
removeShowSection, removeShowSection,
saveShowMetadata, saveShowMetadata,
listShows,
updatePieceUsage, updatePieceUsage,
updateShowSection, updateShowSection,
type EditableShowStatus, type EditableShowStatus,
@@ -66,15 +67,15 @@ function actionError(cause: unknown) {
if (code === 'CONFLICT') if (code === 'CONFLICT')
return { return {
status: 409, status: 409,
error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.', error: 'Der Stream wurde inzwischen geändert. Bitte neu laden.',
conflict: true conflict: true
}; };
if (code === 'SHOW_COMPLETED') 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') if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' }; return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND') 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 (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if ( if (
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes( ['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
@@ -84,7 +85,7 @@ function actionError(cause: unknown) {
return { return {
status: 409, status: 409,
error: 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') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
@@ -110,8 +111,24 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, params, url }) => { export const load: PageServerLoad = ({ locals, params, url }) => {
const actor = requireUser(locals.user); const actor = requireUser(locals.user);
const show = getShow(params.id); 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 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') { if (show.status === 'completed') {
return { return {
show, show,
@@ -125,7 +142,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
pieceTemplates: [], pieceTemplates: [],
createCommandId: randomUUID(), createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false } filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false },
streamNavigation
}; };
} }
const pieces = listPieces({ const pieces = listPieces({
@@ -154,7 +172,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
categoryId: url.searchParams.get('category') ?? '', categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1', includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1' includePlanned: url.searchParams.get('includePlanned') === '1'
} },
streamNavigation
}; };
}; };
+49 -30
View File
@@ -9,6 +9,7 @@
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte'; import ShareManagement from '#lib/components/ShareManagement.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -171,7 +172,7 @@
function removeDraggedUsage() { function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return; if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) { if (!confirm('Piece aus diesem Stream entfernen?')) {
finishDrag(); finishDrag();
return; return;
} }
@@ -204,14 +205,16 @@
} }
</script> </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} {#if data.historical}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap historical-show-page"> <div class="page-wrap historical-show-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a> <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> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -224,7 +227,7 @@
</div> </div>
<form method="POST" action="?/duplicateShow"> <form method="POST" action="?/duplicateShow">
<input type="hidden" name="commandId" value={data.duplicateCommandId} /> <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> </form>
</div> </div>
</header> </header>
@@ -300,11 +303,14 @@
</section>{/each} </section>{/each}
</div> </div>
</div> </div>
</div>
{:else} {:else}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap builder-page"> <div class="page-wrap builder-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <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> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
@@ -337,7 +343,7 @@
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div> <div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings"> <details class="show-settings">
<summary>Sendungsdaten und Twitch-Texte</summary> <summary>Streamdaten und Twitch-Texte</summary>
<form <form
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
method="POST" method="POST"
@@ -385,7 +391,7 @@
> >
<div class="wide-field settings-submit"> <div class="wide-field settings-submit">
<span>{copyMessage}</span><button class="primary-button" type="submit" <span>{copyMessage}</span><button class="primary-button" type="submit"
>Sendungsdaten speichern</button >Streamdaten speichern</button
> >
</div> </div>
</form> </form>
@@ -402,7 +408,7 @@
class:active={mobilePane === 'fundus'} class:active={mobilePane === 'fundus'}
aria-pressed={mobilePane === 'fundus'} aria-pressed={mobilePane === 'fundus'}
type="button" type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button onclick={() => (mobilePane = 'fundus')}>Pieces</button
> >
<button <button
class:active={mobilePane === 'plan'} class:active={mobilePane === 'plan'}
@@ -431,12 +437,12 @@
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Quelle</p> <p class="eyebrow">Quelle</p>
<h2 id="source-title">Planbarer Fundus</h2> <h2 id="source-title">Planbare Pieces</h2>
</div> </div>
<span>{visiblePieces.length}</span> <span>{visiblePieces.length}</span>
</header> </header>
{#if dropTarget === 'source'}<div class="drag-remove-target"> {#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} </div>{/if}
<details class="plan-piece-create"> <details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary> <summary>Neues Piece direkt einplanen</summary>
@@ -464,11 +470,11 @@
</details> </details>
<form id="builder-filters" method="GET" class="builder-filter"> <form id="builder-filters" method="GET" class="builder-filter">
<label <label
><span class="sr-only">Fundus durchsuchen</span><input ><span class="sr-only">Pieces durchsuchen</span><input
type="search" type="search"
name="q" name="q"
value={data.filters.query} value={data.filters.query}
placeholder="Fundus durchsuchen" placeholder="Pieces durchsuchen"
/></label /></label
> >
<label <label
@@ -482,7 +488,7 @@
> >
<button class="text-button" type="submit">Filtern</button> <button class="text-button" type="submit">Filtern</button>
</form> </form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern"> <div class="builder-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input <input
type="checkbox" type="checkbox"
@@ -523,7 +529,8 @@
> >
<div> <div>
<span class="category-label" <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> ><span>{formatClock(piece.durationSeconds)}</span>
</div> </div>
<h3> <h3>
@@ -535,7 +542,8 @@
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || {piece.format} · {piece.topics.map((item) => item.name).join(', ') ||
'Ohne Thema'} 'Ohne Thema'}
</p> </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} /> <PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small> <small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div> </div>
@@ -569,8 +577,8 @@
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span> <span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2> <h2>Keine verfügbaren Pieces</h2>
<p> <p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Bereich Pieces
markieren. sendefertig markieren.
</p> </p>
</div>{/if} </div>{/if}
</section> </section>
@@ -582,7 +590,7 @@
> >
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Sendungsplan</p> <p class="eyebrow">Streamplan</p>
<h2 id="run-title">Ablauf</h2> <h2 id="run-title">Ablauf</h2>
</div> </div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span> <span>{formatDuration(data.show.totalDurationSeconds)}</span>
@@ -603,7 +611,10 @@
animate:flip={{ duration: 180 }} animate:flip={{ duration: 180 }}
> >
{#if dropPreview?.kind === 'section' && dropPreview.position === index} {#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> <span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div> </div>
{/if} {/if}
@@ -791,7 +802,7 @@
dropAtPreview(); dropAtPreview();
}} }}
> >
Leerer Abschnitt · Piece aus dem Fundus hinzufügen oder hierher ziehen Leerer Abschnitt · Piece hinzufügen oder hierher ziehen
</div> </div>
{/if} {/if}
<details class="section-editor"> <details class="section-editor">
@@ -844,7 +855,8 @@
method="POST" method="POST"
action="?/removeSection" action="?/removeSection"
onsubmit={(event) => 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 <input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden" type="hidden"
@@ -878,12 +890,17 @@
class="add-section-form" class="add-section-form"
> >
<input type="hidden" name="revision" value={data.show.revision} /><label <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 ><label
>Kategorie<select name="categoryId" >Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option ><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id} >{#each data.taxonomy.categories as category (category.id)}<option
>{category.name}</option value={category.id}>{category.name}</option
>{/each}</select >{/each}</select
></label ></label
><label ><label
@@ -959,7 +976,8 @@
ondragstart={(event) => startDrag('usage', usage.id, event)} ondragstart={(event) => startDrag('usage', usage.id, event)}
ondragend={finishDrag} ondragend={finishDrag}
> >
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small> <strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small
>
</div> </div>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input <input type="hidden" name="revision" value={data.show.revision} /><input
@@ -971,8 +989,8 @@
name="sectionId" name="sectionId"
required required
><option value="">Abschnitt wählen</option ><option value="">Abschnitt wählen</option
>{#each data.show.sections as section (section.id)}<option value={section.id} >{#each data.show.sections as section (section.id)}<option
>{section.name}</option value={section.id}>{section.name}</option
>{/each}</select >{/each}</select
></label ></label
><button class="secondary-button" type="submit">Einplanen</button> ><button class="secondary-button" type="submit">Einplanen</button>
@@ -982,7 +1000,7 @@
method="POST" method="POST"
action="?/removeUsage" action="?/removeUsage"
onsubmit={(event) => 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 <input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden" type="hidden"
@@ -991,8 +1009,8 @@
/><button /><button
class="text-button danger-text" class="text-button danger-text"
type="submit" type="submit"
aria-label={`${usage.title} aus Sendung entfernen`} aria-label={`${usage.title} aus Stream entfernen`}
>Aus Sendung entfernen</button >Aus Stream entfernen</button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
@@ -1072,4 +1090,5 @@
<input type="hidden" name="usageId" value={removedUsageId} /> <input type="hidden" name="usageId" value={removedUsageId} />
</form> </form>
</div> </div>
</div>
{/if} {/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>
+17 -1
View File
@@ -22,7 +22,8 @@ import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#l
import { import {
createTechnicalAccess, createTechnicalAccess,
listTechnicalAccesses, listTechnicalAccesses,
revokeTechnicalAccess revokeTechnicalAccess,
updateTechnicalAccess
} from '#lib/server/technical-access.js'; } from '#lib/server/technical-access.js';
import { import {
inviteUser, inviteUser,
@@ -343,6 +344,21 @@ export const actions = {
return fail(400, { intent: 'technicalAccess', error: actionError(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 }) => { revokeTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+91 -207
View File
@@ -1,12 +1,19 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import FeedEditorDialog from '#lib/components/FeedEditorDialog.svelte';
import IconPicker from '#lib/components/IconPicker.svelte'; import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte'; import ShareManagement from '#lib/components/ShareManagement.svelte';
import TechnicalAccessDialog from '#lib/components/TechnicalAccessDialog.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte'; import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void }; 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) { function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault(); if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
@@ -20,124 +27,60 @@
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p> <p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header> </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" class="form-message error"
role="alert" role="alert"
> >
{form.error} {form.error}
</div>{/if} </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" class="form-message success"
role="status" role="status"
> >
{form.success} {form.success}
</div>{/if} </div>{/if}
<h2 class="section-heading">Schnittstellen</h2> <h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-panel"> <section class="quiet-panel integration-catalog">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<p class="eyebrow">Import</p> <p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2> <h2>RSS-Feeds</h2>
</div> </div>
<span>{data.feeds.length} eingerichtet</span> <button class="compact-add-button" type="button" onclick={() => feedDialog.showModal()}>
<AppIcon name="plus" /> Feed hinzufügen
</button>
</div> </div>
<p> <p class="catalog-note">
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang. Eingang.
</p> </p>
<details class="integration-create"> <div class="catalog-settings-list integration-settings-list">
<summary class="compact-add-button"><AppIcon name="plus" /> Feed hinzufügen</summary>
<form class="integration-form" method="POST" action="?/saveFeed">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
placeholder="https://example.org/feed.xml"
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value="60"
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
>
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
</details>
<div class="integration-list">
{#each data.feeds as feed (feed.id)} {#each data.feeds as feed (feed.id)}
<details> <article class:inactive={!feed.isEnabled} class="catalog-setting-row">
<summary <button
><span class="catalog-entry-main"
><strong>{feed.name}</strong><small 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 >{feed.lastError
? `Fehler: ${feed.lastError}` ? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc : feed.lastFetchedAtUtc
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}` ? `Zuletzt ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'}</small : 'Noch nicht abgerufen'} · {feed.isEnabled ? 'Automatisch' : 'Pausiert'}</small
></span
><AppIcon name="pen" /></summary
> >
<form class="integration-form" method="POST" action="?/saveFeed"> </span>
<input type="hidden" name="feedId" value={feed.id} /> <AppIcon name="pen" />
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label> </button>
<label <div class="catalog-entry-actions">
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
value={feed.url}
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value={feed.refreshIntervalMinutes}
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={feed.defaultCategoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
abrufen</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
<div class="row-actions">
<form method="POST" action="?/fetchFeed"> <form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} /><button <input type="hidden" name="feedId" value={feed.id} />
class="text-button" <button type="submit" aria-label={`${feed.name} jetzt abrufen`} title="Jetzt abrufen"
type="submit">Jetzt abrufen</button ><AppIcon name="arrow-rotate-right" /></button
> >
</form> </form>
<form <form
@@ -146,74 +89,82 @@
onsubmit={(event) => onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()} !confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
> >
<input type="hidden" name="feedId" value={feed.id} /><button <input type="hidden" name="feedId" value={feed.id} />
class="text-button danger-text" <button
type="submit">Feed löschen</button class="danger-text"
type="submit"
aria-label={`${feed.name} löschen`}
title="Feed löschen"><AppIcon name="trash" /></button
> >
</form> </form>
</div> </div>
</details> </article>
{/each} {/each}
{#if !data.feeds.length}<p class="empty-copy">Noch keine RSS-Feeds eingerichtet.</p>{/if}
</div> </div>
</section> </section>
<FeedEditorDialog bind:this={feedDialog} categories={data.taxonomy.categories} />
<section class="quiet-panel integration-panel"> <section class="quiet-panel integration-catalog">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<p class="eyebrow">Externe Assistenz</p> <p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2> <h2>Technische Zugänge</h2>
</div> </div>
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a> <button
class="compact-add-button"
type="button"
onclick={() => technicalAccessDialog.showModal()}
>
<AppIcon name="plus" /> Zugang hinzufügen
</button>
</div> </div>
<p> <p class="catalog-note">
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein. idempotent sein. <a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI öffnen</a>
</p> </p>
{#if form?.technicalToken}<div class="token-reveal" role="status"> <div class="catalog-settings-list integration-settings-list">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if}
<details class="integration-create">
<summary class="compact-add-button"><AppIcon name="plus" /> Zugang hinzufügen</summary>
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
<label
>Name<input
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
</details>
<div class="integration-list">
{#each data.technicalAccesses as access (access.id)} {#each data.technicalAccesses as access (access.id)}
<div class="access-row"> <article class:inactive={Boolean(access.revokedAtUtc)} class="catalog-setting-row">
<span <button
><strong>{access.name}</strong><small 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 >{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen' ? 'Widerrufen'
: access.lastUsedAtUtc : access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}` ? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small : 'Noch nicht genutzt'}</small
></span
>{#if !access.revokedAtUtc}<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} /><button
class="secondary-button"
type="submit">Widerrufen</button
> >
</form>{/if} </span>
</div> {#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} {/each}
{#if !data.technicalAccesses.length}<p class="empty-copy">
Noch keine technischen Zugänge angelegt.
</p>{/if}
</div> </div>
</section> </section>
<TechnicalAccessDialog bind:this={technicalAccessDialog} {form} />
<section class="quiet-panel account-panel"> <section class="quiet-panel account-panel">
<div class="panel-heading"> <div class="panel-heading">
@@ -324,7 +275,7 @@
</button> </button>
</div> </div>
<p class="catalog-note"> <p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten. Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Streams erhalten.
</p> </p>
<div class="catalog-settings-list tool-settings-list"> <div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)} {#each data.tools as tool (tool.id)}
@@ -457,78 +408,11 @@
</div> </div>
<style> <style>
.integration-panel { .integration-catalog {
display: grid;
gap: 1rem;
margin-block: 1.5rem; margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
} }
.integration-form { .integration-catalog .catalog-note a {
display: grid; white-space: nowrap;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.8rem;
align-items: end;
}
.integration-create > summary {
width: fit-content;
cursor: pointer;
list-style: none;
}
.integration-create > summary::-webkit-details-marker {
display: none;
}
.integration-create[open] > .integration-form {
margin-top: 0.8rem;
}
.integration-form label:not(.check-row) {
display: grid;
gap: 0.35rem;
}
.check-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.6rem;
}
.integration-list {
display: grid;
gap: 0.5rem;
}
.integration-list details,
.access-row {
padding: 0.75rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.integration-list summary,
.access-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.integration-list summary > span,
.access-row > span {
display: grid;
gap: 0.2rem;
}
.integration-list small {
color: var(--text-muted);
}
.row-actions {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.token-reveal {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border: 2px solid var(--color-accent);
border-radius: 0.7rem;
overflow-wrap: anywhere;
}
.token-reveal code {
user-select: all;
} }
</style> </style>
+362 -101
View File
@@ -31,12 +31,14 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
} }
async function openEditorialField(page: Page, heading: string) { async function openEditorialField(page: Page, heading: string) {
// Fundus pieces show all writing areas at once; usage editors still use tabs.
if (!(await page.getByRole('button', { name: 'Streamtext', exact: true }).count())) return;
if (heading.includes('Recherche')) { if (heading.includes('Recherche')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) { } else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} else { } else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click(); await page.getByRole('button', { name: 'Streamtext', exact: true }).click();
} }
} }
@@ -46,6 +48,18 @@ async function editInlineTitle(scope: Page | Locator, title: string, value: stri
await scope.getByLabel('Titel', { exact: true }).press('Enter'); await scope.getByLabel('Titel', { exact: true }).press('Enter');
} }
async function fillMarkdownSource(page: Page, label: string, value: string) {
const fieldNames: Record<string, string> = {
'Private Recherche': 'researchText',
'Öffentlicher Vorspann': 'presentationText'
};
const field = page.locator(`.rich-markdown-field:has(input[name="${fieldNames[label]}"])`);
if (!(await field.locator('textarea:visible').count())) {
await field.getByRole('button', { name: 'Markdown' }).click();
}
await field.locator('textarea').fill(value);
}
async function login(page: Page, email = adminEmail, password = adminPassword) { async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('E-Mail').fill(email); await page.getByLabel('E-Mail').fill(email);
@@ -56,7 +70,10 @@ async function login(page: Page, email = adminEmail, password = adminPassword) {
async function openSeededShow(page: Page) { async function openSeededShow(page: Page) {
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByRole('link', { name: /Herbstsendung: Kleine Welten/ }).click(); await page
.locator('.show-card-list')
.getByRole('link', { name: /Herbstsendung: Kleine Welten/ })
.click();
await expect(page).toHaveURL(/\/sendungen\/show-october$/); await expect(page).toHaveURL(/\/sendungen\/show-october$/);
} }
@@ -247,6 +264,80 @@ function removeCompletionFixture() {
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true }); rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
} }
function addRssSelectionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const now = '2026-10-26T12:00:00.000Z';
const insertFeed = database.prepare(
`INSERT INTO feed_sources
(id, name, url, is_enabled, refresh_interval_minutes, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, 1, 60, ?, ?)`
);
const insertEntry = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
database.transaction(() => {
insertFeed.run('e2e-feed-a', 'E2E Quelle A', 'https://example.org/e2e-a.xml', now, now);
insertFeed.run('e2e-feed-b', 'E2E Quelle B', 'https://example.org/e2e-b.xml', now, now);
for (let index = 1; index <= 25; index += 1) {
const published = new Date(Date.UTC(2026, 8, index)).toISOString();
insertEntry.run(
`e2e-entry-a-${index}`,
'e2e-feed-a',
`a-${index}`,
`E2E Quelle A Beitrag ${index}`,
`https://example.org/a/${index}`,
`Auszug für Beitrag ${index} aus Quelle A.`,
published,
now,
now
);
}
for (let index = 1; index <= 20; index += 1) {
const published = new Date(Date.UTC(2026, 7, index)).toISOString();
insertEntry.run(
`e2e-entry-b-${index}`,
'e2e-feed-b',
`b-${index}`,
`E2E Quelle B Beitrag ${index}`,
`https://example.org/b/${index}`,
`Auszug für Beitrag ${index} aus Quelle B.`,
published,
now,
now
);
}
})();
database.close();
}
function removeIntegrationFixtures() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
const pieceIds = database
.prepare(
"SELECT piece_id AS id FROM feed_entries WHERE id LIKE 'e2e-entry-%' AND piece_id IS NOT NULL"
)
.all() as { id: string }[];
database.prepare("DELETE FROM feed_sources WHERE id LIKE 'e2e-feed-%'").run();
for (const piece of pieceIds) database.prepare('DELETE FROM pieces WHERE id = ?').run(piece.id);
const accessIds = database
.prepare("SELECT id FROM technical_accesses WHERE name LIKE 'E2E API-Zugang%'")
.all() as { id: string }[];
for (const access of accessIds) {
database.prepare('DELETE FROM audit_events WHERE target_id = ?').run(access.id);
database.prepare('DELETE FROM technical_accesses WHERE id = ?').run(access.id);
}
})();
database.close();
}
function addPresentationFixture() { function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
@@ -404,6 +495,7 @@ test.beforeAll(async ({ browser }) => {
expect(sessionCookie?.sameSite).toBe('Lax'); expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup'); await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
await page.locator('.app-header .account-menu > summary').click();
await page.getByRole('button', { name: 'Abmelden', exact: true }).click(); await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/); await expect(page).toHaveURL(/\/login$/);
await page.close(); await page.close();
@@ -413,6 +505,35 @@ test.beforeEach(async ({ page }) => {
await login(page); await login(page);
}); });
test('dashboard, global search and responsive primary navigation use the shared app chrome', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Was als Nächstes ansteht' })).toBeVisible();
for (const label of ['Start', 'Eingang', 'Pieces', 'Streams', 'Live']) {
await expect(
page.locator('.main-navigation').getByRole('link', { name: label, exact: true })
).toBeVisible();
}
await page.getByPlaceholder('Pieces und Streams suchen').fill('Herstellerangaben');
await page.getByPlaceholder('Pieces und Streams suchen').press('Enter');
await expect(page).toHaveURL(/\/suche\?q=Herstellerangaben/);
await expect(page.getByRole('heading', { name: 'Pieces', exact: true })).toBeVisible();
await expect(page.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible();
await expect(page.getByText('Recherche', { exact: true })).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await expect(page.locator('.app-header')).toBeHidden();
await expect(
page.locator('.mobile-header').getByPlaceholder('Suchen', { exact: true })
).toBeVisible();
await expect(page.locator('.mobile-navigation').getByRole('link')).toHaveCount(5);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('start-mobile.png'), fullPage: true });
});
test('invitations, direct authorization, locking and password reset work end to end', async ({ test('invitations, direct authorization, locking and password reset work end to end', async ({
page, page,
browser browser
@@ -492,16 +613,23 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus'); await page.goto('/fundus');
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Pieces', exact: true }).first()).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.app-header')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung'); await expect(page.locator('.app-header .account-menu > summary')).toBeVisible();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
const navigationBox = await page.locator('.fundus-navigation').boundingBox();
const listBox = await page.locator('.fundus-list-pane').boundingBox();
expect(navigationBox?.x).toBeLessThan(listBox?.x ?? 0);
await expect(page.locator('.fundus-context-pane')).toHaveCount(0);
await page await page
.locator('.filter-picker') .locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true)); .evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await page await page
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' }) .getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
.click(); .click();
@@ -513,6 +641,15 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await expect(page.locator('.piece-card').first()).toContainText( await expect(page.locator('.piece-card').first()).toContainText(
'Die kleine Konsole mit dem großen Dreh' 'Die kleine Konsole mit dem großen Dreh'
); );
await page
.locator('.piece-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true })
.click();
await expect(page).toHaveURL(/\/fundus\/piece-console\?includePlanned=1$/);
await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus\?includePlanned=1$/);
await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await page await page
.locator('.filter-picker') .locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true)); .evaluate((element) => ((element as HTMLDetailsElement).open = true));
@@ -521,13 +658,9 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
const secondRow = await page.locator('.piece-card').nth(1).boundingBox(); const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x); expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0); expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130); expect(firstRow?.height ?? 999).toBeLessThan(150);
const searchBox = await page.locator('.search-field').boundingBox(); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('Pixelart');
const statusBox = await page.getByLabel('Status').boundingBox(); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter');
expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page await page
.locator('.filter-picker') .locator('.filter-picker')
@@ -541,11 +674,13 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await page.getByRole('button', { name: 'Zurücksetzen' }).click(); await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page.getByRole('button', { name: 'Darstellung: System' }).click(); await page.locator('.app-header .account-menu > summary').click();
await page.getByRole('button', { name: 'Hell' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload(); await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible(); await page.locator('.app-header .account-menu > summary').click();
await expect(page.getByRole('button', { name: 'Hell' })).toHaveAttribute('aria-pressed', 'true');
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
}); });
@@ -560,17 +695,7 @@ test('sent and planned source filters stay independent and removing the last usa
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0); ).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable')); await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page await page.getByRole('link', { name: /Abgeschlossen/ }).click();
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
const usedPiece = page const usedPiece = page
.locator('.piece-card') .locator('.piece-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' }); .filter({ hasText: 'Warum praktische Effekte wieder auffallen' });
@@ -584,9 +709,9 @@ test('sent and planned source filters stay independent and removing the last usa
await usedPiece.getByText('Vollständige Verwendungshistorie').click(); await usedPiece.getByText('Vollständige Verwendungshistorie').click();
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2); await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable'); await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte'); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('praktische Effekte');
await page.getByRole('button', { name: 'Filter anwenden' }).click(); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter');
await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked(); await expect(page).toHaveURL(/view=completed/);
await expect(usedPiece).toBeVisible(); await expect(usedPiece).toBeVisible();
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'), path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'),
@@ -594,11 +719,11 @@ test('sent and planned source filters stay independent and removing the last usa
}); });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click(); await page.locator('.show-create-panel > summary').click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung'); await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByRole('radio', { name: /Standardablauf/ }).check(); await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Stream anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
const consolePiece = page const consolePiece = page
.locator('.builder-source-card') .locator('.builder-source-card')
@@ -606,7 +731,7 @@ test('sent and planned source filters stay independent and removing the last usa
await consolePiece.getByRole('button', { name: 'Hinzufügen' }).click(); await consolePiece.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(consolePiece).toHaveCount(0); await expect(consolePiece).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill(''); await page.getByPlaceholder('Pieces durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect( await expect(
@@ -619,7 +744,7 @@ test('sent and planned source filters stay independent and removing the last usa
.locator('.builder-source-card') .locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) .filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0); ).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked(); await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await expect( await expect(
@@ -627,7 +752,7 @@ test('sent and planned source filters stay independent and removing the last usa
.locator('.builder-source-card') .locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant'); ).toContainText('Eingeplant');
await page.getByPlaceholder('Fundus durchsuchen').fill(''); await page.getByPlaceholder('Pieces durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck(); await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck();
@@ -650,7 +775,7 @@ test('sent and planned source filters stay independent and removing the last usa
page.once('dialog', (dialog) => dialog.accept()); page.once('dialog', (dialog) => dialog.accept());
await page await page
.locator('.reserve-usage') .locator('.reserve-usage')
.getByRole('button', { name: /aus Sendung entfernen/ }) .getByRole('button', { name: /aus Stream entfernen/ })
.click(); .click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck(); await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect( await expect(
@@ -659,7 +784,7 @@ test('sent and planned source filters stay independent and removing the last usa
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible(); ).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('sendungsfilter-mobile.png'), path: testInfo.outputPath('sendungsfilter-mobile.png'),
@@ -694,7 +819,9 @@ test('show overview switches persistently between list, calendar and mobile agen
await page.getByRole('link', { name: 'Nächster Monat' }).click(); await page.getByRole('link', { name: 'Nächster Monat' }).click();
await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Kalender Novembertermin' })).toBeVisible(); await expect(
page.locator('.show-calendar-event').filter({ hasText: 'Kalender Novembertermin' })
).toBeVisible();
await page.reload(); await page.reload();
await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute( await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute(
'aria-pressed', 'aria-pressed',
@@ -713,7 +840,7 @@ test('show overview switches persistently between list, calendar and mobile agen
}); });
await page.getByRole('button', { name: 'Liste' }).click(); await page.getByRole('button', { name: 'Liste' }).click();
await expect(page.getByRole('heading', { name: 'Geplante Sendungen' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Streams', exact: true }).last()).toBeVisible();
} finally { } finally {
removeCalendarFixture(); removeCalendarFixture();
} }
@@ -744,7 +871,7 @@ test('preparation opens and updates the concrete scheduled usage', async ({ page
await expect(editor.getByRole('combobox', { name: 'Video-URL', exact: true })).toBeVisible(); await expect(editor.getByRole('combobox', { name: 'Video-URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Videotitel')).toBeVisible(); await expect(editor.getByLabel('Videotitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Text', exact: true }).check(); await editor.getByRole('radio', { name: 'Text', exact: true }).check();
await editor.getByRole('button', { name: 'Sendungstext', exact: true }).click(); await editor.getByRole('button', { name: 'Streamtext', exact: true }).click();
await editor.getByText('Dauer und Einplanung', { exact: true }).click(); await editor.getByText('Dauer und Einplanung', { exact: true }).click();
await editor.getByLabel('Vorbereitungsstatus').selectOption('ready'); await editor.getByLabel('Vorbereitungsstatus').selectOption('ready');
await editor.getByRole('button', { name: 'Verwendung speichern' }).click(); await editor.getByRole('button', { name: 'Verwendung speichern' }).click();
@@ -782,16 +909,18 @@ test('desktop builder keeps the fundus beside the wider run sheet', async ({ pag
}); });
}); });
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => { test('narrow desktop retains the horizontal navigation and switches the builder panes', async ({
page
}) => {
await page.setViewportSize({ width: 960, height: 850 }); await page.setViewportSize({ width: 960, height: 850 });
await openSeededShow(page); await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.app-header')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden(); await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden(); await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
@@ -807,7 +936,7 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
await expect(page.locator('.sidebar')).toBeHidden(); await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden(); await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
@@ -832,10 +961,10 @@ test('a show can be planned from a template with independent usages and reserve'
test.slow(); test.slow();
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click(); await page.locator('.show-create-panel > summary').click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC'); await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByRole('radio', { name: /Standardablauf/ }).check(); await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Stream anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
const showUrl = page.url(); const showUrl = page.url();
await expect(page.locator('.run-section')).toHaveCount(5); await expect(page.locator('.run-section')).toHaveCount(5);
@@ -855,7 +984,7 @@ test('a show can be planned from a template with independent usages and reserve'
await sectionTransfer.dispose(); await sectionTransfer.dispose();
await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0); await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
const source = page const source = page
.locator('.builder-source-card') .locator('.builder-source-card')
@@ -894,7 +1023,7 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung'); await expect(page.locator('dialog[open]')).toContainText('Streamfassung');
await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible(); await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
@@ -903,7 +1032,7 @@ test('a show can be planned from a template with independent usages and reserve'
}); });
await page await page
.locator('dialog[open]') .locator('dialog[open]')
.getByRole('button', { name: 'Aus Fundus aktualisieren' }) .getByRole('button', { name: 'Aus Piece aktualisieren' })
.click(); .click();
await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(page.locator('dialog[open]')).toHaveCount(0);
usages = page usages = page
@@ -982,7 +1111,7 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung'); await expect(page.locator('dialog[open]')).toContainText('Streamfassung');
await expect( await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' }) page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible(); ).toBeVisible();
@@ -1019,7 +1148,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click(); .click();
await expect(savedUsage.locator('textarea[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen'); await expect(savedUsage.locator('input[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const duplicatedUsage = page const duplicatedUsage = page
.locator('.editable-usage') .locator('.editable-usage')
@@ -1030,7 +1159,7 @@ test('a show can be planned from a template with independent usages and reserve'
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' }) .getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click(); .click();
await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15'); await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15');
await expect(duplicatedUsage.nth(1).locator('textarea[name="ownNotes"]')).toHaveValue( await expect(duplicatedUsage.nth(1).locator('input[name="ownNotes"]')).toHaveValue(
'Andere Perspektive' 'Andere Perspektive'
); );
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
@@ -1047,10 +1176,10 @@ test('a show can be planned from a template with independent usages and reserve'
}); });
await page await page
.locator('dialog[open]') .locator('dialog[open]')
.getByRole('button', { name: 'Aus Sendung entfernen' }) .getByRole('button', { name: 'Aus Stream entfernen' })
.scrollIntoViewIfNeeded(); .scrollIntoViewIfNeeded();
await expect( await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' }) page.locator('dialog[open]').getByRole('button', { name: 'Aus Stream entfernen' })
).toBeVisible(); ).toBeVisible();
await page await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]') .locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
@@ -1173,6 +1302,124 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish'); await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish');
}); });
test('rss selection and integration catalogs keep deliberate choices', async ({
page
}, testInfo) => {
addRssSelectionFixture();
try {
await page.context().route('https://example.org/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>RSS-Original</title>'
})
);
await page.setViewportSize({ width: 1100, height: 760 });
await setTheme(page, 'light');
await page.goto('/eingang');
const rssPanel = page.locator('.rss-inbox-panel');
await expect(rssPanel).toContainText('45 Beiträge sind verfügbar');
await rssPanel.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click();
const rssDialog = page.getByRole('dialog', { name: 'RSS-Beiträge auswählen' });
await expect(rssDialog).toBeVisible();
await expect(rssDialog.getByRole('option')).toHaveCount(20);
await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click();
await expect(rssDialog).toContainText('25 verfügbare Beiträge');
const firstChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 25/ });
await firstChoice.press('Enter');
await expect(firstChoice).toHaveAttribute('aria-selected', 'true');
const [original] = await Promise.all([
page.waitForEvent('popup'),
firstChoice.getByRole('link', { name: 'Original öffnen' }).click()
]);
await original.close();
await expect(firstChoice).toHaveAttribute('aria-selected', 'true');
await rssDialog.getByRole('button', { name: 'Weiter' }).click();
const secondChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 1/ });
await secondChoice.press(' ');
await expect(secondChoice).toHaveAttribute('aria-selected', 'true');
await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click();
await expect(
rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' })
).toBeEnabled();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'dark'));
await expectNoHorizontalOverflow(page);
await expect(
rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' })
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('rss-selection-mobile-dark.png') });
await rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' }).click();
await expect(page.getByRole('status')).toContainText('2 Beiträge in den Eingang übernommen');
await expect(rssPanel).toContainText('43 Beiträge sind verfügbar');
await expect(page.getByText('E2E Quelle A Beitrag 25', { exact: true })).toBeVisible();
await rssPanel.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click();
await expect(rssDialog.getByText('E2E Quelle A Beitrag 25', { exact: true })).toHaveCount(0);
await expect(rssDialog.getByText('E2E Quelle A Beitrag 1', { exact: true })).toHaveCount(0);
await rssDialog.getByRole('button', { name: 'RSS-Auswahl schließen' }).click();
await page.setViewportSize({ width: 1100, height: 800 });
await setTheme(page, 'light');
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Feed hinzufügen' }).click();
let feedDialog = page.getByRole('dialog', { name: 'Feed hinzufügen' });
await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung');
await feedDialog.getByLabel('Feed-URL').fill('https://example.org/e2e-admin.xml');
await feedDialog.getByLabel('Intervall in Minuten').fill('90');
await feedDialog.getByLabel('Standardkategorie').selectOption({ index: 1 });
await feedDialog.getByRole('button', { name: 'Feed anlegen' }).click();
const feedEntry = page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeiten' });
await expect(feedEntry).toBeVisible();
await feedEntry.click();
feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung' });
await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung bearbeitet');
await feedDialog.getByLabel('Intervall in Minuten').fill('120');
await feedDialog.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(
page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' })
).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'dark'));
await page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' }).click();
feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung bearbeitet' });
await expect(feedDialog.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('feed-editor-mobile-dark.png') });
await feedDialog.getByRole('button', { name: 'Feeddialog schließen' }).click();
await page.setViewportSize({ width: 1100, height: 800 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'light'));
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: 'Zugang hinzufügen' }).click();
let accessDialog = page.getByRole('dialog', { name: 'API-Zugang hinzufügen' });
await accessDialog.getByLabel('Name').fill('E2E API-Zugang');
await accessDialog.getByLabel('Berechtigung').selectOption('read_write');
await accessDialog.getByRole('button', { name: 'Zugang anlegen' }).click();
await expect(accessDialog.getByText('Token, einmalig sichtbar')).toBeVisible();
const token = await accessDialog.locator('code').textContent();
expect(token?.length ?? 0).toBeGreaterThan(20);
await accessDialog.getByRole('button', { name: 'Token kopieren' }).click();
await expect(accessDialog.getByText('Token kopiert.')).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(token);
await accessDialog.getByRole('button', { name: 'Zugangsdialog schließen' }).click();
await page.getByRole('button', { name: 'E2E API-Zugang bearbeiten' }).click();
accessDialog = page.getByRole('dialog', { name: 'E2E API-Zugang' });
await accessDialog.getByLabel('Name').fill('E2E API-Zugang bearbeitet');
await accessDialog.getByLabel('Berechtigung').selectOption('read');
await accessDialog.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(
page.getByRole('button', { name: 'E2E API-Zugang bearbeitet bearbeiten' })
).toContainText('Nur lesen');
await page.screenshot({
path: testInfo.outputPath('integration-catalogs-light.png'),
fullPage: true
});
} finally {
removeIntegrationFixtures();
}
});
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => { test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung'); await page.goto('/verwaltung');
for (const width of [390, 1280]) { for (const width of [390, 1280]) {
@@ -1200,7 +1447,6 @@ test('tools stay editable in pieces after catalog deactivation', async ({ page }
await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true });
await page.goto('/fundus/piece-quiz'); await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.getByText('Werkzeuge auswählen', { exact: true }).click(); await page.getByText('Werkzeuge auswählen', { exact: true }).click();
const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ }); const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ });
await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible(); await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible();
@@ -1232,7 +1478,6 @@ test('tools stay editable in pieces after catalog deactivation', async ({ page }
await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true });
await page.goto('/fundus/piece-quiz'); await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist'); await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist');
await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert'); await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert');
await expect(page.getByLabel('Arbeitslink')).toHaveValue( await expect(page.getByLabel('Arbeitslink')).toHaveValue(
@@ -1266,8 +1511,9 @@ test('validated YouTube and TikTok players switch without exposing private field
); );
await page.goto('/live?show=e2e-presentation'); await page.goto('/live?show=e2e-presentation');
await page.getByText('Menü', { exact: true }).click(); await page.getByText('Menü', { exact: true }).click();
await page.getByText('Ausgabe einrichten', { exact: true }).click();
const popupPromise = page.waitForEvent('popup'); const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); await page.getByRole('button', { name: 'Mit diesem Stream verbinden und öffnen' }).click();
const output = await popupPromise; const output = await popupPromise;
await expect(output.locator('.output-content')).toHaveCount(0); await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
@@ -1317,8 +1563,9 @@ test('live control persists state and exposes only explicit presentation data',
setSectionDuration('section-stories', 300); setSectionDuration('section-stories', 300);
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click(); await page
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); .getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true })
.click();
const png = Buffer.from( const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
@@ -1330,11 +1577,9 @@ test('live control persists state and exposes only explicit presentation data',
}); });
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click(); await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Nur Bild', exact: true }).check(); await page.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Nur Bild', exact: true })).toBeChecked(); await expect(page.getByRole('radio', { name: 'Nur Bild', exact: true })).toBeChecked();
await page.getByRole('radio', { name: 'Text', exact: true }).check(); await page.getByRole('radio', { name: 'Text', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
@@ -1344,14 +1589,14 @@ test('live control persists state and exposes only explicit presentation data',
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.first(); .first();
await plannedConsole.getByRole('button', { name: /bearbeiten und verschieben/ }).click(); await plannedConsole.getByRole('button', { name: /bearbeiten und verschieben/ }).click();
await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click(); await plannedConsole.getByRole('button', { name: 'Aus Piece aktualisieren' }).click();
await page.goto('/live?show=show-october'); await page.goto('/live?show=show-october');
await expect(page.getByRole('heading', { name: 'Bereit für die Sendung' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Bereit für den Stream' })).toBeVisible();
await expect(page.locator('.sidebar')).toHaveCount(0); await expect(page.locator('.sidebar')).toHaveCount(0);
await expect(page.locator('.mobile-header')).toHaveCount(0); await expect(page.locator('.mobile-header')).toHaveCount(0);
await expect(page.locator('.mobile-navigation')).toHaveCount(0); await expect(page.locator('.mobile-navigation')).toHaveCount(0);
await page.getByRole('button', { name: 'Sendung starten' }).click(); await page.getByRole('button', { name: 'Stream starten' }).click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(page.getByRole('button', { name: 'Zurück', exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: 'Zurück', exact: true })).toBeDisabled();
await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/); await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/);
@@ -1360,11 +1605,23 @@ test('live control persists state and exposes only explicit presentation data',
const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' }); const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' });
await expect(liveMenu).toBeVisible(); await expect(liveMenu).toBeVisible();
await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten'); await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten');
await expect(liveMenu).toContainText('Läuft');
await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0); await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0);
await expect(liveMenu.getByRole('button', { name: 'Pausieren', exact: true })).toHaveCount(0);
await expect(liveMenu.getByRole('button', { name: 'Fortsetzen', exact: true })).toHaveCount(0);
await expect(liveMenu.getByText(/zurücksetzen/)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused(); await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(liveMenu).toBeHidden(); await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused(); await expect(liveMenuTrigger).toBeFocused();
await page.getByRole('button', { name: 'Stream pausieren' }).click();
await expect(page.getByRole('button', { name: 'Stream fortsetzen' })).toBeVisible();
await liveMenuTrigger.click();
await expect(liveMenu).toContainText('Pausiert');
await expect(liveMenu.getByRole('button', { name: 'Fortsetzen', exact: true })).toHaveCount(0);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Stream fortsetzen' }).click();
await expect(page.getByRole('button', { name: 'Stream pausieren' })).toBeVisible();
await liveMenuTrigger.press('Enter'); await liveMenuTrigger.press('Enter');
await expect(liveMenu).toBeVisible(); await expect(liveMenu).toBeVisible();
await page.locator('.live-current').click({ position: { x: 8, y: 8 } }); await page.locator('.live-current').click({ position: { x: 8, y: 8 } });
@@ -1392,9 +1649,10 @@ test('live control persists state and exposes only explicit presentation data',
await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await page.getByText('Menü', { exact: true }).click(); await page.getByText('Menü', { exact: true }).click();
await page.getByText('Ausgabe einrichten', { exact: true }).click();
const [popup] = await Promise.all([ const [popup] = await Promise.all([
page.waitForEvent('popup'), page.waitForEvent('popup'),
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click() page.getByRole('button', { name: 'Mit diesem Stream verbinden und öffnen' }).click()
]); ]);
await popup.waitForURL(/\/heinzlive\/default#/); await popup.waitForURL(/\/heinzlive\/default#/);
const outputUrl = popup.url(); const outputUrl = popup.url();
@@ -1693,7 +1951,7 @@ test('live control persists state and exposes only explicit presentation data',
'Warum praktische Effekte wieder auffallen' 'Warum praktische Effekte wieder auffallen'
); );
await page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click(); await page.getByRole('button', { name: 'Erledigen & Streamende' }).click();
await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible();
await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen'); await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
@@ -1712,12 +1970,19 @@ test('live control persists state and exposes only explicit presentation data',
window.open = () => null; window.open = () => null;
}); });
await page.getByText('Menü', { exact: true }).click(); await page.getByText('Menü', { exact: true }).click();
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); await page.getByText('Ausgabe einrichten', { exact: true }).click();
await page.getByRole('button', { name: 'Ausgabe öffnen' }).click();
await expect( await expect(
page page
.getByRole('status') .getByRole('status')
.filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' }) .filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' })
).toBeVisible(); ).toBeVisible();
await page.getByText('Menü', { exact: true }).click();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Stream beenden' }).click();
await page.getByText('Menü', { exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Regiemenü' })).toContainText('Lauf beendet');
await expect(page.getByRole('button', { name: 'Beendeten Lauf wiederaufnehmen' })).toBeVisible();
await anonymous.close(); await anonymous.close();
await popup.close(); await popup.close();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
@@ -1748,7 +2013,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
const planningGuest = await browser.newContext(); const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage(); const planningGuestPage = await planningGuest.newPage();
await planningGuestPage.goto(planningSharePath!); await planningGuestPage.goto(planningSharePath!);
await expect(planningGuestPage.getByText('Aktuelle Sendungsplanung · Nur lesen')).toBeVisible(); await expect(planningGuestPage.getByText('Aktuelle Streamplanung · Nur lesen')).toBeVisible();
await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible(); await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible(); await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage const guestThemePreference = await planningGuestPage
@@ -1785,7 +2050,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
await page.goto('/live?show=e2e-completion'); await page.goto('/live?show=e2e-completion');
await page.getByRole('button', { name: 'Menü', exact: true }).click(); await page.getByRole('button', { name: 'Menü', exact: true }).click();
await page.getByRole('button', { name: 'Sendung abschließen' }).click(); await page.getByRole('button', { name: 'Stream abschließen' }).click();
const completion = page.locator('.completion-dialog'); const completion = page.locator('.completion-dialog');
await expect(completion).toBeVisible(); await expect(completion).toBeVisible();
await expect(completion).toContainText('1 gesendet'); await expect(completion).toContainText('1 gesendet');
@@ -1795,13 +2060,18 @@ test('completion, archive and protected guest sharing work end to end', async ({
await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click(); await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click();
await expect(completion).toBeHidden(); await expect(completion).toBeHidden();
await page.goto('/archiv'); await page.goto('/sendungen?state=completed');
await expect(page.getByRole('link', { name: 'E2E Abschluss' })).toBeVisible(); await expect(
await page.getByRole('link', { name: 'E2E Abschluss' }).click(); page.locator('.show-card-list').getByRole('link', { name: /E2E Abschluss/ })
await expect(page.getByText('Unveränderliche Sendungshistorie')).toBeVisible(); ).toBeVisible();
await page
.locator('.show-card-list')
.getByRole('link', { name: /E2E Abschluss/ })
.click();
await expect(page.getByText('Unveränderliche Streamhistorie')).toBeVisible();
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible(); await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible(); await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Streamdaten speichern' })).toHaveCount(0);
expect( expect(
await page await page
.locator('.historical-section') .locator('.historical-section')
@@ -1827,7 +2097,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
})); }));
expect(dialogStyle.borderRadius).toBeGreaterThan(0); expect(dialogStyle.borderRadius).toBeGreaterThan(0);
expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)'); expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion'); await shareManagement.getByLabel('Stream').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation'); await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort'); await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click(); await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
@@ -1845,10 +2115,10 @@ test('completion, archive and protected guest sharing work end to end', async ({
expect(passwordHtml).not.toContain('password_hash'); expect(passwordHtml).not.toContain('password_hash');
expect(passwordHtml).not.toContain('e2e-completion'); expect(passwordHtml).not.toContain('e2e-completion');
await guestPage.getByLabel('Passwort').fill('falsch'); await guestPage.getByLabel('Passwort').fill('falsch');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click(); await guestPage.getByRole('button', { name: 'Stream öffnen' }).click();
await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch'); await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch');
await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort'); await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click(); await guestPage.getByRole('button', { name: 'Stream öffnen' }).click();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible(); await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible(); await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0); await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
@@ -1912,20 +2182,24 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
page, page,
browser browser
}, testInfo) => { }, testInfo) => {
test.setTimeout(45_000);
await page.setViewportSize({ width: 1280, height: 1000 }); await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus'); await page.goto('/fundus');
await page.locator('.template-picker > summary').click(); await page.locator('.template-picker > summary').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click(); await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url(); const pieceUrl = page.url();
const writingBox = await page.locator('.piece-writing-stack').first().boundingBox();
const metadataBox = await page.locator('.piece-meta-details').boundingBox();
expect(writingBox?.x).toBeLessThan(metadataBox?.x ?? 0);
await expectNoHorizontalOverflow(page);
await editInlineTitle(page, 'Neues Fundstück', 'E2E Fundstück mit Markdown'); await editInlineTitle(page, 'Neues Fundstück', 'E2E Fundstück mit Markdown');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Editor schließen' }).click(); await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await page.getByRole('link', { name: 'E2E Fundstück mit Markdown', exact: true }).click();
await expect(page).toHaveURL(pieceUrl); await expect(page).toHaveURL(pieceUrl);
await page.getByText('Status, Dauer und Einordnung').click();
await page await page
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.'); .fill('Persistiert nach Neuladen und ist gezielt suchbar.');
@@ -1934,21 +2208,21 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await page.getByText('Themen auswählen', { exact: true }).click(); await page.getByText('Themen auswählen', { exact: true }).click();
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page await fillMarkdownSource(page, 'Private Recherche', 'Nur intern: **Hintergrund prüfen**.');
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte'); await openEditorialField(page, 'Moderationsstichpunkte');
await page await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true }) .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Den Kern in zwei Sätzen erklären.'); .fill('Den Kern in zwei Sätzen erklären.');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue( await expect(page.locator('input[name="researchText"]')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );
await openEditorialField(page, 'Präsentationstext'); await openEditorialField(page, 'Präsentationstext');
await page await fillMarkdownSource(
.getByRole('textbox', { name: 'Öffentlicher Vorspann', exact: true }) page,
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); 'Öffentlicher Vorspann',
'## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'
);
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last(); const source = page.locator('.source-row').last();
@@ -1965,10 +2239,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect( await expect(
page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' }) page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' })
).toBeVisible(); ).toBeVisible();
await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue( await expect(page.locator('input[name="researchText"]')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' 'Nur intern: **Hintergrund prüfen**.'
); );
@@ -1976,7 +2249,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
); );
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Bild', { exact: true }).setInputFiles({ await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png', name: 'redaktionsbild.png',
mimeType: 'image/png', mimeType: 'image/png',
@@ -1999,7 +2271,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await concurrent.goto(pieceUrl); await concurrent.goto(pieceUrl);
await editInlineTitle(page, 'E2E Fundstück mit Markdown', 'E2E Fundstück, extern aktualisiert'); await editInlineTitle(page, 'E2E Fundstück mit Markdown', 'E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent await concurrent
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.'); .fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -2010,7 +2281,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect( await expect(
concurrent.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' }) concurrent.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' })
).toBeVisible(); ).toBeVisible();
await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.' 'Diese veraltete Änderung darf nicht gewinnen.'
); );
@@ -2021,7 +2291,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect( await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible(); ).toBeVisible();
await page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }).click(); await page.getByRole('link', { name: 'E2E Fundstück, extern aktualisiert', exact: true }).click();
await page.getByRole('button', { name: 'Piece archivieren' }).click(); await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/); await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' }); const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
@@ -2054,7 +2324,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark'); await setTheme(page, 'dark');
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
@@ -2078,11 +2347,10 @@ test('a quickly captured video can be written and previewed before classificatio
await page.getByRole('button', { name: 'Im Eingang ablegen' }).click(); await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/); await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click(); await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue( await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ' 'https://youtu.be/dQw4w9WgXcQ'
); );
await expect(page.getByLabel('Status')).toBeHidden(); await expect(page.getByLabel('Status')).toBeVisible();
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page await page
@@ -2093,7 +2361,6 @@ test('a quickly captured video can be written and previewed before classificatio
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true }) .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.'); .fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check(); await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
@@ -2108,9 +2375,7 @@ test('a quickly captured video can be written and previewed before classificatio
await preview.close(); await preview.close();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible(); await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
@@ -2119,7 +2384,6 @@ test('link preview uses one existing source, persists manual fields and isolates
page page
}, testInfo) => { }, testInfo) => {
await page.goto('/fundus/piece-console'); await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
@@ -2127,7 +2391,6 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.getByLabel('Vorschaubild-URL').fill(''); await page.getByLabel('Vorschaubild-URL').fill('');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau'); await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau');
@@ -2153,10 +2416,8 @@ test('link preview uses one existing source, persists manual fields and isolates
}); });
await preview.close(); await preview.close();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private'); await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
const blockedPreview = page.waitForResponse((response) => const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview') response.url().includes('?/loadPreview')
); );