Release 0.8.0

This commit is contained in:
hnzio committed 2026-10-27 14:57:17 +01:00
1 parent 21c0de7dd9
commit 07f2c4607b
47 files changed
+3535 -524

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.7.3",
"version": "0.8.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+4 -4
View File
@@ -182,7 +182,7 @@ Zielressource erneut. MCP kann keine Inhalte schreiben.
### In ChatGPT einrichten
1. Zuerst Version `0.7.3` unter der endgültigen öffentlichen HTTPS-Origin
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
@@ -260,8 +260,8 @@ Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Nach einem Update
auf `0.7.3` führt der bestehende Startprozess Migration `0020_oauth_mcp.sql`
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.
@@ -282,7 +282,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und
`better-sqlite3`, `@node-rs/argon2`, Tiptap, Marked, sanitize-html, file-type und
Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
installierten Baum.
+40
View File
@@ -1,5 +1,45 @@
# 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 |
+62 -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
spätere API dieselben Regeln verwenden.
## Navigation, Suche und Arbeitsflächen
`+layout.svelte` stellt für angemeldete Nutzer genau die globalen Bereiche Start,
Eingang, Pieces, Streams und Live bereit. Desktop verwendet eine horizontale
Kopfzeile, Mobilgeräte eine obere Such-/Kontoleiste und eine Safe-Area-fähige
Bottom-Navigation. `UserMenu.svelte` bündelt Verwaltung, Abmeldung und die weiter
in `localStorage` gespeicherte explizite Auswahl Hell, Dunkel oder System.
Die Root-Route komponiert das Dashboard ausschließlich aus `listShows()`,
`getShow()`, `listPieces()` und den vorhandenen Verwendungskontexten. Sie besitzt
keinen eigenen persistenten Zustand. Pieces und Streams verwenden getrennte,
lokal gespeicherte Ein-/Ausklappzustände ihrer Kontextnavigation; URL-Parameter
bleiben die fachliche Quelle für lokale Suche und Filter.
`global-search.ts` ist eine serverseitige, ausschließlich hinter `requireUser`
verwendete Projektion. Sie durchsucht aktive und archivierte Pieces einschließlich
redaktioneller Langtexte sowie Metadaten und Usage-Snapshots aller Streams. Die
Route `/suche` gibt gruppierte Positivprojektionen mit ID, Titel, Status, internem
Ziel, Feldbezeichnung und begrenztem Ausschnitt aus. Gelöschte Pieces werden nicht
global angeboten. Es gibt keinen externen Index und keine neue Persistenz.
Die normale Piece-Arbeitsmenge schließt Originale mit materialisierter
Verwendungshistorie aus. Dieselben Originale werden unter `view=completed` mit
ihren verlinkten Streams angezeigt und bleiben über die bestehenden
Planungsfilter erneut einplanbar. Abgeschlossene Streams werden nicht mehr als
eigener globaler Archivbereich behandelt, sondern von `listShows()` in der
Streamübersicht gefiltert. Der technische `/archiv`-Pfad bleibt für archivierte
und gelöschte Pieces bestehen.
Der Usage-Editor verwendet weiterhin `PieceEditorDialog` und dieselben
Form-Actions. Auf breiten Ansichten öffnet das Dialogelement nicht-modal als
rechte Arbeitsleiste; ohne ausgewählte Usage wird es nicht gerendert. Unterhalb
der mobilen Grenze öffnet dieselbe Komponente modal und nutzt den verfügbaren
Viewport.
## Laufzeit und Speicher
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
@@ -380,12 +415,12 @@ Status und Termin.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Auf dem Desktop liegt sie als feste horizontale Glasleiste oberhalb der
Arbeitsfläche. Bis einschließlich `52rem` ersetzen eine kompakte obere Leiste
und die untere Hauptnavigation diesen Desktopkopf vollständig. Auf breiten
Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem`
werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch
schmale Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
@@ -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
nicht in ihren Client-Chunk.
Der Fundus ist auf breiten Ansichten ein Raster aus Bibliotheksnavigation,
mittlerer Liste beziehungsweise Piece-Schreibfläche und rechter Kontextspalte.
Kleinere Desktopfenster blenden zuerst den rechten Kontext aus; mobil wird die
Bibliotheksnavigation zur horizontalen Auswahl und im geöffneten Editor ganz
ausgeblendet. URL-Parameter tragen Suche und Filter über den Routenwechsel,
`sessionStorage` ausschließlich die zugehörige flüchtige Listenposition.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
Arbeitsblatt ohne Desktopdialog an. Titel, Einleitung, Moderationsstichpunkte,
Interaktion, Überleitung, private Recherche, Quellen und Werkzeuge bilden die
mittlere Schreibfläche. Status, Dauer, Einordnung, Präsentationsmodi und Medien
liegen auf breiten Ansichten rechts. Alle Werte werden weiterhin gemeinsam über
die bestehende `save`-Action und Piece-Revision gespeichert; separate Bild- und
Präsentationsactions bleiben eigene serverseitige Mutationen.
`RichMarkdownField` erstellt je sichtbarem Langtext genau eine Tiptap-Instanz mit
StarterKit und offizieller Markdown-Erweiterung. Der Formularwert bleibt rohes
Markdown. Eine Bearbeitung aktualisiert ihn aus `getMarkdown()`, während das
bloße Initialisieren keinen Input auslöst. Weicht die erste Serialisierung vom
gespeicherten Text ab, startet das Feld in der Markdown-Quellansicht. Der Nutzer
kann diese Ansicht jederzeit erreichen; Autosave erzeugt die Editorinstanz nicht
neu.
## Sicherheitsgrenzen
+50
View File
@@ -570,3 +570,53 @@ Quellen, geprüft am 27. Oktober 2026:
- <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.
+50
View File
@@ -1,5 +1,55 @@
# 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
+59
View File
@@ -8,6 +8,65 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert.
## Redaktioneller Arbeitsbereich 0.8.0
- Die sichtbare Anwendungssprache verwendet durchgehend Pieces und Streams; die
stabilen technischen Routen und Datenmodelle bleiben unverändert. Desktop und
Mobilgerät teilen die fünf Hauptbereiche Start, Eingang, Pieces, Streams und
Live. Suche, explizite Theme-Auswahl, Verwaltung und Abmeldung liegen in der
gemeinsamen Kopfzeile beziehungsweise im Benutzermenü.
- `/` ist ein echtes Dashboard aus vorhandenem nächsten Stream, dessen kompaktem
Ablauf, aktuellen Pieces, Schnellaktionen und weiteren geplanten Streams. Es
erzeugt weder Aufgaben noch andere redaktionelle Daten.
- Die globale, angemeldeten Nutzern vorbehaltene Suche gruppiert Treffer nach
Pieces und Streams. Sie berücksichtigt archivierte Pieces, abgeschlossene
Streams, private redaktionelle Langtexte und eingefrorene Stream-Snapshots und
liefert nur einen kurzen Trefferkontext mit Fundort.
- Pieces und Streams besitzen einklappbare Kontextnavigationen mit lokaler Suche.
Bereits gesendete Pieces verschwinden aus dem normalen Arbeitsbestand und
liegen mit verlinkter Verwendungshistorie unter Abgeschlossen; archivierte und
gelöschte Pieces bleiben unter Pieces erreichbar. Abgeschlossene Streams sind
über den Streamfilter erreichbar und behalten den gespeicherten Ablauf.
- Die Piece-Liste ist eine kompakte, vollständig anklickbare Zeilenansicht ohne
erzwungene Bildplatzhalter. Ohne Auswahl existiert keine rechte Leerspalte. In
der Streamplanung öffnet der bestehende vollständige Usage-Editor auf Desktop
als nicht-modale rechte Arbeitsleiste und mobil weiterhin als Vollbilddialog.
- Für diese Änderung war keine Datenbankmigration nötig. Rollen, Rechte,
Revisionsschutz, Snapshotgrenzen, Markdown-Speicherung und bestehende Actions
bleiben erhalten.
Geprüft wurden 62 von 62 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
21 von 21 Chromium-Abläufe. Dashboard, globale Suche, abgeschlossene Pieces und
Streams, Desktop-/Mobilnavigation, rechte Usage-Arbeitsleiste sowie die
bestehenden Rollen-, Live-, Freigabe-, Autosave- und Präsentationsabläufe waren
erfolgreich. Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
## Horizontale Fundus-Werkbank 0.7.4
- Die allgemeine Desktopnavigation verläuft horizontal über der Anwendung. Der
Fundus nutzt darunter eine dreispaltige Werkbank aus Bibliotheksnavigation,
Liste beziehungsweise Schreibfläche sowie Kontext und Präsentation. Bei
kleinerem Platz fällt zuerst die Kontextspalte weg; mobil werden Navigation
und Editor gezielt einzeln statt als gestauchte Dreispaltenansicht gezeigt.
- Fundusnavigation, Status, Favoriten, Archiv, Papierkorb und Themen zeigen aus
den bestehenden Daten abgeleitete Zähler. Suche, Taxonomiefilter,
Verwendungsfilter und Listenposition bleiben beim Wechsel in ein Piece und
zurück erhalten.
- Der Fundus-Editor ist auf Desktop kein Dialog mehr. Sendungstext und private
Recherche stehen dauerhaft in der mittleren Schreibfläche; Status, Dauer,
Einordnung und die öffentliche Präsentationsfläche liegen ausschließlich
rechts. Bestehende Autosave-, Revisions-, Quellen-, Werkzeug-, Medien- und
Präsentationsactions bleiben unverändert getrennt.
- Alle formatierbaren Langtexte in Fundus- und Sendungsfassung verwenden eine
gemeinsame Tiptap-Komponente. Gespeichert und an Form-Actions übermittelt wird
weiterhin Markdown. Eine erreichbare Quellansicht übernimmt nicht verlustfrei
abbildbares Markdown, ohne es bereits beim Öffnen umzuschreiben.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und die
gezielten Chromium-Abläufe für Fundus, Piece-Autosave, Sendungsfassung,
Werkzeuge, Live-Snapshots sowie Link-/Videopräsentation. Die abschließende
vollständige Browsermatrix ist im Abnahmenachweis festgehalten.
## Lesender MCP-Zugang 0.7.3
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und
+567 -2
View File
@@ -1,14 +1,18 @@
{
"name": "sendewerk",
"version": "0.7.3",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.7.3",
"version": "0.8.0",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"@tiptap/core": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0",
@@ -1234,6 +1238,404 @@
"vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@tiptap/core": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz",
"integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz",
"integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz",
"integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz",
"integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz",
"integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz",
"integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz",
"integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz",
"integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz",
"integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz",
"integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz",
"integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz",
"integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz",
"integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz",
"integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz",
"integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz",
"integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz",
"integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz",
"integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz",
"integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz",
"integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz",
"integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz",
"integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/markdown": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.32.0.tgz",
"integrity": "sha512-y+pGAu4QMkXBMxhBoLvc6FWucQ1EWIoPi+Ajf4rAh5OlZtSV3CjBwyK6/xTwO7mvQxtoUf7DD23CzSaj5nCTBA==",
"license": "MIT",
"dependencies": {
"marked": "^17.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/markdown/node_modules/marked": {
"version": "17.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
"integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@tiptap/pm": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.4",
"prosemirror-commands": "^1.7.2",
"prosemirror-dropcursor": "^1.8.4",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.1",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.12",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.2",
"prosemirror-view": "^1.42.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz",
"integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/extension-blockquote": "3.32.0",
"@tiptap/extension-bold": "3.32.0",
"@tiptap/extension-bullet-list": "3.32.0",
"@tiptap/extension-code": "3.32.0",
"@tiptap/extension-code-block": "3.32.0",
"@tiptap/extension-document": "3.32.0",
"@tiptap/extension-dropcursor": "3.32.0",
"@tiptap/extension-gapcursor": "3.32.0",
"@tiptap/extension-hard-break": "3.32.0",
"@tiptap/extension-heading": "3.32.0",
"@tiptap/extension-horizontal-rule": "3.32.0",
"@tiptap/extension-italic": "3.32.0",
"@tiptap/extension-link": "3.32.0",
"@tiptap/extension-list": "3.32.0",
"@tiptap/extension-list-item": "3.32.0",
"@tiptap/extension-list-keymap": "3.32.0",
"@tiptap/extension-ordered-list": "3.32.0",
"@tiptap/extension-paragraph": "3.32.0",
"@tiptap/extension-strike": "3.32.0",
"@tiptap/extension-text": "3.32.0",
"@tiptap/extension-underline": "3.32.0",
"@tiptap/extensions": "3.32.0",
"@tiptap/pm": "3.32.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
@@ -3578,6 +3980,12 @@
"node": ">=10"
}
},
"node_modules/linkifyjs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz",
"integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==",
"license": "MIT"
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -3986,6 +4394,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4400,6 +4814,145 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4513,6 +5066,12 @@
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -5276,6 +5835,12 @@
"node": ">=12.20.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.7.3",
"version": "0.8.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
@@ -25,6 +25,10 @@
},
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"@tiptap/core": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0",
+1067
View File
File diff suppressed because it is too large. Load diff
+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>
+35 -15
View File
@@ -6,6 +6,8 @@
labelledby,
activeSection = $bindable(),
onrequestclose,
inline = false,
sidePanel = false,
header,
children,
footer
@@ -13,12 +15,14 @@
labelledby: string;
activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void;
inline?: boolean;
sidePanel?: boolean;
header: Snippet;
children: Snippet;
footer: Snippet;
} = $props();
let dialog: HTMLDialogElement;
let dialog = $state<HTMLDialogElement>();
let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 };
@@ -30,24 +34,18 @@
}
export function showModal() {
if (!dialog.open) dialog.showModal();
if (!inline && dialog && !dialog.open) {
if (sidePanel && window.matchMedia('(min-width: 52.01rem)').matches) dialog.show();
else dialog.showModal();
}
}
export function close() {
dialog.close();
if (!inline) dialog?.close();
}
</script>
<dialog
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
{#snippet editorChrome()}
<header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div>
<button
@@ -57,12 +55,13 @@
onclick={onrequestclose}><AppIcon name="xmark" /></button
>
</header>
{#if !inline}
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button
type="button"
class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button
onclick={() => selectSection('script')}>Streamtext</button
>
<button
type="button"
@@ -77,6 +76,27 @@
onclick={() => selectSection('presentation')}>Präsentation</button
>
</nav>
{/if}
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer>
</dialog>
{/snippet}
{#if inline}
<section class="piece-editor-dialog fundus-editor-surface" aria-labelledby={labelledby}>
{@render editorChrome()}
</section>
{:else}
<dialog
bind:this={dialog}
class="piece-editor-dialog"
class:piece-editor-side-panel={sidePanel}
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
{@render editorChrome()}
</dialog>
{/if}
+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>
+5 -5
View File
@@ -123,7 +123,7 @@
</button>
</div>
<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>
</section>
{:else}
@@ -153,7 +153,7 @@
</button>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
Der Gast sieht nur diesen Stream und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
@@ -187,8 +187,8 @@
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
>Stream<select name="showId" required>
<option value="">Stream wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
@@ -317,7 +317,7 @@
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
<p class="quiet-copy">Für diesen Stream gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
+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>
+18 -17
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import RichMarkdownField from './RichMarkdownField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
@@ -130,9 +130,10 @@
bind:activeSection
labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose}
sidePanel
>
{#snippet header()}
<p class="eyebrow">Sendungsfassung</p>
<p class="eyebrow">Streamfassung</p>
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet}
@@ -153,31 +154,31 @@
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3>
<h3>Streamtext</h3>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
<RichMarkdownField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro}
/>
<MarkdownEditorField
<RichMarkdownField
name="moderation"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
<RichMarkdownField
name="interaction"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interaction}
/>
<MarkdownEditorField
<RichMarkdownField
name="transition"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
@@ -230,7 +231,7 @@
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
<RichMarkdownField
name="research"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -238,10 +239,10 @@
privateField
bind:value={draft.research}
/>
<MarkdownEditorField
<RichMarkdownField
name="ownNotes"
label="Interne Notizen zu dieser Verwendung"
placeholder="Absprachen und Hinweise nur für diese Sendung"
placeholder="Absprachen und Hinweise nur für diesen Stream"
privateField
bind:value={draft.ownNotes}
/>
@@ -260,7 +261,7 @@
<p class="eyebrow">Snapshot</p>
<h3>Werkzeuge</h3>
</div>
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
<small>Name und Start-URL bleiben Teil der Streamfassung.</small>
</div>
{#if draft.tools.length}<div class="source-list">
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
@@ -280,7 +281,7 @@
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
>
</div>{/each}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Streamfassung.</p>{/if}
</section>
</section>
@@ -316,7 +317,7 @@
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
<RichMarkdownField
name="presentation"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
@@ -438,7 +439,7 @@
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>Aus Piece aktualisieren</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
@@ -470,7 +471,7 @@
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
@@ -478,7 +479,7 @@
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>Aus Stream entfernen</button
>
</form>
</div>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark';
const preferences: { value: Preference; label: string; icon: string }[] = [
{ value: 'light', label: 'Hell', icon: 'sun' },
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'display' }
];
let { displayName, role }: { displayName: string; role: 'barkeeper' | 'thekencrew' } = $props();
let preference = $state<Preference>('system');
function apply(next: Preference) {
preference = next;
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = next === 'system' ? (dark ? 'dark' : 'light') : next;
document.documentElement.dataset.themePreference = next;
localStorage.setItem('sendewerk-theme', next);
}
onMount(() => {
const stored = localStorage.getItem('sendewerk-theme');
preference = preferences.some((item) => item.value === stored)
? (stored as Preference)
: 'system';
const media = window.matchMedia('(prefers-color-scheme: dark)');
const followSystem = () => preference === 'system' && apply('system');
media.addEventListener('change', followSystem);
return () => media.removeEventListener('change', followSystem);
});
</script>
<details class="account-menu user-menu">
<summary aria-label={`Benutzermenü für ${displayName}`}>
<span aria-hidden="true">{displayName.slice(0, 1).toUpperCase()}</span>
</summary>
<div class="user-menu-popover">
<header>
<strong>{displayName}</strong>
<small>{role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</header>
{#if role === 'barkeeper'}
<a class="user-menu-link" href={resolve('/verwaltung')}>
<AppIcon name="gear" /><span>Einstellungen & Verwaltung</span>
</a>
{/if}
<fieldset>
<legend>Erscheinungsbild</legend>
<div class="theme-menu-options">
{#each preferences as item (item.value)}
<button
type="button"
class:active={preference === item.value}
aria-pressed={preference === item.value}
onclick={() => apply(item.value)}
>
<AppIcon name={item.icon} />
{item.label}
</button>
{/each}
</div>
</fieldset>
<form method="POST" action="/logout">
<button class="user-menu-link" type="submit"
><AppIcon name="right-from-bracket" /> Abmelden</button
>
</form>
</div>
</details>
+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 };
}
+13 -11
View File
@@ -59,7 +59,7 @@ const tools = [
{
name: 'list_pieces',
title: 'Pieces durchsuchen',
description: 'Listet und durchsucht redaktionelle Pieces im Sendewerk-Fundus.',
description: 'Listet und durchsucht redaktionelle Pieces in Sendewerk.',
inputSchema: {
type: 'object',
properties: {
@@ -108,8 +108,8 @@ const tools = [
},
{
name: 'list_shows',
title: 'Sendungen auflisten',
description: 'Listet Sendungen mit Status, Termin, Dauer und Abschnittszahl.',
title: 'Streams auflisten',
description: 'Listet Streams mit Status, Termin, Dauer und Abschnittszahl.',
inputSchema: {
type: 'object',
properties: {
@@ -138,8 +138,8 @@ const tools = [
},
{
name: 'get_show_rundown',
title: 'Sendungsablauf lesen',
description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve einer Sendung.',
title: 'Streamablauf lesen',
description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve eines Streams.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
@@ -265,9 +265,11 @@ function callTool(name: string, input: unknown, user: AuthUser) {
const offset = integer(input.offset, 0, 0);
const all = listPieces({ statuses, query });
return toolResult({
pieces: all
.slice(offset, offset + limit)
.map(({ mediaId: _mediaId, formatId: _formatId, ...piece }) => piece),
pieces: all.slice(offset, offset + limit).map(({ mediaId, formatId, ...piece }) => {
void mediaId;
void formatId;
return piece;
}),
pagination: { limit, offset, total: all.length }
});
}
@@ -315,7 +317,7 @@ function callTool(name: string, input: unknown, user: AuthUser) {
case 'get_show_rundown': {
exactKeys(input, ['id']);
const show = getShow(stringArgument(input.id, 100, true));
if (!show) return toolError('Sendung nicht gefunden.');
if (!show) return toolError('Stream nicht gefunden.');
return toolResult({
id: show.id,
title: show.title,
@@ -373,7 +375,7 @@ export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
_meta: {
'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' }
},
instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.',
instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.',
ttlMs: 3_600_000,
cacheScope: 'private'
}
@@ -392,7 +394,7 @@ export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
protocolVersion,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'Sendewerk', version: '0.7.3' },
instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.'
instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.'
}
};
}
+2
View File
@@ -109,6 +109,8 @@ export function registerOAuthClient(input: unknown): OAuthClient {
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
+1 -1
View File
@@ -181,7 +181,7 @@ describe('show builder data layer', () => {
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
expect(getShow(blank.id)).toMatchObject({
title: 'Neue Sendung',
title: 'Neuer Stream',
startsAtUtc: null,
sections: [],
reserve: []
+1 -1
View File
@@ -1021,7 +1021,7 @@ export function getPieceUsageContexts(
export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase();
const id = randomUUID();
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
const title = cleanText(input.title ?? 'Neuer Stream', 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString();
const create = database.transaction(() => {
+37 -38
View File
@@ -5,22 +5,21 @@
import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import UserMenu from '#lib/components/UserMenu.svelte';
let { children, data } = $props();
const primaryNavigation = [
{ href: '/', label: 'Start', icon: 'house' },
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
{ href: '/fundus', label: 'Pieces', icon: 'library' },
{ href: '/sendungen', label: 'Streams', icon: 'shows' },
{ href: '/live', label: 'Live', icon: 'tower-broadcast' }
] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
return (
page.url.pathname.startsWith(href) || (href === '/sendungen' && page.url.pathname === '/live')
page.url.pathname.startsWith(href) || (href === '/fundus' && page.url.pathname === '/archiv')
);
}
</script>
@@ -28,7 +27,7 @@
<svelte:head>
<meta
name="description"
content="Sendewerk organisiert redaktionelle Inhalte und heinzlive-Sendungen."
content="Sendewerk organisiert redaktionelle Pieces und heinzlive-Streams."
/>
</svelte:head>
@@ -49,8 +48,8 @@
<main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user}
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<header class="app-header">
<a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
@@ -68,47 +67,47 @@
{/each}
</nav>
<div class="sidebar-footer">
{#if data.user.role === 'barkeeper'}
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
class:active={isActive(administration.href)}
aria-current={isActive(administration.href) ? 'page' : undefined}
>
<NavIcon name={administration.icon} />
<span>{administration.label}</span>
</a>
</nav>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
<div class="app-header-actions">
<form class="global-search-form" method="GET" action={resolve('/suche')} role="search">
<label>
<AppIcon name="magnifying-glass" />
<span class="sr-only">Pieces und Streams durchsuchen</span>
<input
name="q"
type="search"
value={page.url.pathname === '/suche' ? (page.url.searchParams.get('q') ?? '') : ''}
placeholder="Pieces und Streams suchen"
/>
</label>
</form>
<ThemeToggle />
<UserMenu displayName={data.user.displayName} role={data.user.role} />
</div>
</aside>
</header>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden"
><AppIcon name="right-from-bracket" /></button
<form
class="global-search-form mobile-global-search"
method="GET"
action={resolve('/suche')}
role="search"
>
<label>
<AppIcon name="magnifying-glass" />
<span class="sr-only">Pieces und Streams durchsuchen</span>
<input name="q" type="search" placeholder="Suchen" />
</label>
</form>
<ThemeToggle />
<UserMenu displayName={data.user.displayName} role={data.user.role} />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)}
{#each primaryNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
+32 -1
View File
@@ -1,7 +1,38 @@
import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js';
import { listPieces } from '#lib/server/content.js';
import { getPieceUsageContexts, getShow, listShows } from '#lib/server/shows.js';
export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login');
if (!locals.user) redirect(307, '/login');
const now = new Date().toISOString();
const activeShows = listShows(['draft', 'ready', 'live']);
const orderedShows = [...activeShows].sort((left, right) => {
if (left.status === 'live' && right.status !== 'live') return -1;
if (right.status === 'live' && left.status !== 'live') return 1;
if (left.startsAtUtc && right.startsAtUtc) {
const leftPast = left.startsAtUtc < now;
const rightPast = right.startsAtUtc < now;
if (leftPast !== rightPast) return leftPast ? 1 : -1;
return leftPast
? right.startsAtUtc.localeCompare(left.startsAtUtc)
: left.startsAtUtc.localeCompare(right.startsAtUtc);
}
return left.startsAtUtc ? -1 : right.startsAtUtc ? 1 : 0;
});
const nextShowSummary = orderedShows[0] ?? null;
const nextShow = nextShowSummary ? getShow(nextShowSummary.id) : null;
const pieceCandidates = listPieces({ statuses: ['preparing', 'ready'] });
const contexts = getPieceUsageContexts(pieceCandidates.map((piece) => piece.id));
const currentPieces = pieceCandidates
.filter((piece) => !(contexts[piece.id]?.sentHistory.length > 0) || piece.isFavorite)
.slice(0, 6);
return {
nextShow,
currentPieces,
upcomingShows: orderedShows.filter((show) => show.id !== nextShowSummary?.id).slice(0, 4)
};
}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import { formatDuration } from '#lib/format.js';
let { data } = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Sendefertig';
if (status === 'live') return 'Live';
return 'In Vorbereitung';
}
</script>
<svelte:head><title>Start | Sendewerk</title></svelte:head>
<section class="page-wrap dashboard-page">
<header class="dashboard-heading">
<div>
<p class="eyebrow">Redaktion</p>
<h1>Was als Nächstes ansteht</h1>
</div>
<nav class="dashboard-quick-actions" aria-label="Schnellaktionen">
<a class="secondary-button" href={resolve('/fundus?create=1')}
><AppIcon name="plus" /> Neues Piece</a
>
<a class="primary-button" href={resolve('/sendungen?create=1')}
><AppIcon name="calendar-plus" /> Neuen Stream planen</a
>
</nav>
</header>
<div class="dashboard-grid">
<section class="dashboard-next-stream" aria-labelledby="next-stream-heading">
<header>
<div>
<p class="eyebrow">Nächster Stream</p>
<h2 id="next-stream-heading">{data.nextShow?.title ?? 'Noch kein Stream geplant'}</h2>
{#if data.nextShow}<p>
{berlinDate(data.nextShow.startsAtUtc)} Uhr · {statusLabel(data.nextShow.status)}
</p>{/if}
</div>
{#if data.nextShow}<a
class="text-link"
href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
>Planung öffnen <AppIcon name="arrow-right" /></a
>{/if}
</header>
{#if data.nextShow}
<div class="dashboard-run-list">
{#each data.nextShow.sections as section (section.id)}
<div class="dashboard-run-section">
<span class="run-marker" style:--marker={section.categoryColor ?? 'var(--gold)'}
></span>
<div>
<strong>{section.name}</strong>
<small>{formatDuration(section.durationSeconds)}</small>
{#if section.usages.length}<p>
{section.usages.map((usage) => usage.title).join(' · ')}
</p>{/if}
</div>
</div>
{/each}
{#if !data.nextShow.sections.length}<p class="empty-state">
Der Ablauf ist noch leer.
</p>{/if}
</div>
<div class="dashboard-stream-actions">
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
><AppIcon name="pen" /> Ablauf bearbeiten</a
>
<a
class="primary-button"
href={resolve(`/live?show=${encodeURIComponent(data.nextShow.id)}`)}
><AppIcon name="tower-broadcast" /> Live öffnen</a
>
</div>
{:else}
<div class="empty-state compact">
<p>Lege einen Stream an, um hier den nächsten Ablauf zu sehen.</p>
</div>
{/if}
</section>
<aside class="dashboard-side-stack">
<section aria-labelledby="upcoming-heading">
<header>
<h2 id="upcoming-heading">Weitere Streams</h2>
<a href={resolve('/sendungen')}>Alle</a>
</header>
{#if data.upcomingShows.length}
<div class="dashboard-mini-list">
{#each data.upcomingShows as show (show.id)}
<a href={resolve('/sendungen/[id]', { id: show.id })}
><span
><strong>{show.title}</strong><small>{berlinDate(show.startsAtUtc)} Uhr</small
></span
><AppIcon name="chevron-right" /></a
>
{/each}
</div>
{:else}<p class="empty-state compact">Keine weiteren Streams geplant.</p>{/if}
</section>
</aside>
</div>
<section class="dashboard-pieces" aria-labelledby="current-pieces-heading">
<header>
<div>
<p class="eyebrow">Pieces</p>
<h2 id="current-pieces-heading">Aktuell in Arbeit</h2>
</div>
<a href={resolve('/fundus')}>Alle Pieces <AppIcon name="arrow-right" /></a>
</header>
{#if data.currentPieces.length}
<div class="dashboard-piece-grid">
{#each data.currentPieces as piece (piece.id)}
<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span class={`show-state ${piece.status}`}
>{piece.status === 'ready' ? 'Bereit' : 'In Vorbereitung'}</span
>
<strong>{piece.title}</strong>
<p>{piece.summary || piece.format}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine aktuellen Pieces.</p>{/if}
</section>
</section>
+3 -3
View File
@@ -76,15 +76,15 @@ export const GET: RequestHandler = () =>
}
},
'/shows': {
get: { summary: 'Sendungen lesen', responses: ok },
get: { summary: 'Streams lesen', responses: ok },
post: {
summary: 'Sendung anlegen',
summary: 'Stream anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
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': {
post: {
summary: 'Piece einplanen',
+1 -1
View File
@@ -50,7 +50,7 @@ export const actions = {
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' });
return fail(400, { error: 'Der Stream konnte nicht dupliziert werden.' });
}
}
} satisfies Actions;
+6 -25
View File
@@ -1,41 +1,22 @@
<script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<svelte:head><title>Archivierte Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Archiv</h1>
<p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p>
<p class="eyebrow">Pieces</p>
<h1>Archiviert und gelöscht</h1>
<p class="page-intro">Bewusst abgelegte Pieces bleiben auffindbar und wiederherstellbar.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
{#each data.pieces as piece (piece.id)}<article id={`piece-${piece.id}`}>
<div>
<h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
@@ -57,7 +38,7 @@
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy">
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
Diese Einträge bleiben wiederherstellbar; bestehende Streamfassungen werden nicht verändert.
</p>
{#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article>
+3 -3
View File
@@ -15,7 +15,7 @@
}
</script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head>
<svelte:head><title>Streamfreigabe | Sendewerk</title></svelte:head>
<main class="share-page">
{#if data.status === 'unavailable'}
@@ -38,13 +38,13 @@
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Sendung öffnen</button>
<button class="primary-button" type="submit">Stream öffnen</button>
</form>
</section>
{:else}
<header class="share-heading">
<p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen
{data.share.isCompleted ? 'Abgeschlossener Stream' : 'Aktuelle Streamplanung'} · Nur lesen
</p>
<h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
+8 -34
View File
@@ -5,44 +5,18 @@ import {
createPiece,
deletePieceTemplate,
duplicatePiece,
getTaxonomy,
listPieces,
listPieceTemplates,
setPieceArchived,
setPieceDeleted,
setPieceFavorite
} from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js';
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
};
const pieces = listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
templates: listPieceTemplates(),
filters
...loadFundusWorkspace(url),
templates: listPieceTemplates()
};
};
@@ -54,7 +28,7 @@ function actionError(cause: unknown) {
}
export const actions = {
create: async ({ locals, request }) => {
create: async ({ locals, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
@@ -63,7 +37,7 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
},
deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user);
@@ -75,7 +49,7 @@ export const actions = {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
}
},
duplicate: async ({ locals, request }) => {
duplicate: async ({ locals, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
@@ -84,7 +58,7 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
},
archive: async ({ locals, request }) => {
requireUser(locals.user);
@@ -113,7 +87,7 @@ export const actions = {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return {
intent: 'delete',
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
success: 'Piece gelöscht. Bestehende Streamfassungen bleiben erhalten.'
};
} catch (cause) {
return fail(409, { intent: 'delete', error: actionError(cause) });
+64 -30
View File
@@ -4,8 +4,10 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -15,8 +17,8 @@
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
(showPlannedPieces || !piece.isPlannedInActiveShow)
(data.filters.view === 'completed' || showUsedPieces || piece.sentHistory.length === 0) &&
(data.filters.view === 'completed' || showPlannedPieces || !piece.isPlannedInActiveShow)
)
);
onMount(() => {
@@ -26,9 +28,35 @@
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
const scrollKey = `fundus-scroll:${window.location.search}`;
const storedScroll = Number(sessionStorage.getItem(scrollKey));
if (Number.isFinite(storedScroll))
requestAnimationFrame(() => window.scrollTo(0, storedScroll));
if (new URL(window.location.href).searchParams.get('create') === '1') {
document.querySelector<HTMLDetailsElement>('.template-picker')?.setAttribute('open', '');
}
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function rememberListPosition() {
sessionStorage.setItem(`fundus-scroll:${window.location.search}`, String(window.scrollY));
}
function pieceSearch() {
const search = new SvelteURLSearchParams();
if (data.filters.query) search.set('q', data.filters.query);
if (data.filters.status) search.set('status', data.filters.status);
if (data.filters.formatId) search.set('format', data.filters.formatId);
if (data.filters.topicId) search.set('topic', data.filters.topicId);
if (data.filters.categoryId) search.set('category', data.filters.categoryId);
if (data.filters.favorites) search.set('favorites', '1');
if (data.filters.view) search.set('view', data.filters.view);
if (showUsedPieces) search.set('includeUsed', '1');
if (showPlannedPieces) search.set('includePlanned', '1');
const query = search.toString();
return query ? `?${query}` : '';
}
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
@@ -53,13 +81,16 @@
};
</script>
<svelte:head><title>Fundus | Sendewerk</title></svelte:head>
<svelte:head><title>Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap">
<div class="fundus-workspace fundus-list-workspace">
<FundusNavigation filters={data.filters} counts={data.counts} topics={data.taxonomy.topics} />
<section class="fundus-list-pane">
<header class="page-heading split-heading">
<div>
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
<p class="eyebrow">Redaktionelle Bibliothek</p>
<h1>Pieces</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Streams.</p>
</div>
<div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
@@ -105,22 +136,10 @@
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<form
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<form id="library-filters" class="library-filters" method="GET" aria-label="Pieces filtern">
<div class="fundus-search-row">
<label class="search-field"
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<button class="secondary-button" type="submit">Suchen</button>
<input type="hidden" name="q" value={data.filters.query} />
{#if data.filters.view}<input type="hidden" name="view" value={data.filters.view} />{/if}
<details class="filter-picker">
<summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel">
@@ -136,7 +155,8 @@
>
<label
><span>Format</span><select name="format"
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
><option value="">Alle</option
>{#each data.taxonomy.formats as item (item.id)}<option
value={item.id}
selected={data.filters.formatId === item.id}>{item.name}</option
>{/each}</select
@@ -159,7 +179,7 @@
>{/each}</select
></label
>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<div class="library-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
@@ -176,7 +196,8 @@
name="includePlanned"
value="1"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
onchange={(event) =>
setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
@@ -197,6 +218,12 @@
class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
>
<a
class="piece-row-link"
href={resolve(`/fundus/${encodeURIComponent(piece.id)}${pieceSearch()}` as '/fundus')}
onclick={rememberListPosition}
aria-label={`${piece.title} öffnen`}
></a>
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
<input type="hidden" name="pieceId" value={piece.id} />
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
@@ -214,11 +241,16 @@
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
</span>{/if}
/>{/if}
<div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<h2>
<a
href={resolve(
`/fundus/${encodeURIComponent(piece.id)}${pieceSearch()}` as '/fundus'
)}
onclick={rememberListPosition}>{piece.title}</a
>
</h2>
{#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} />
</div>
@@ -244,7 +276,8 @@
>
</div>
<details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
<summary aria-label={`Aktionen für ${piece.title}`}
><AppIcon name="ellipsis" /></summary
>
<div class="piece-actions-popover">
<form method="POST" action="?/duplicate">
@@ -265,7 +298,7 @@
action="?/delete"
onsubmit={(event) =>
!confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
`Piece „${piece.title}“ löschen? Bestehende Streamfassungen bleiben erhalten.`
) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
@@ -288,4 +321,5 @@
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div>
{/if}
</section>
</div>
+11 -9
View File
@@ -19,6 +19,7 @@ import {
type PieceSourceInput
} from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
import { listTools } from '#lib/server/tools.js';
function renderedFields(
@@ -138,12 +139,13 @@ function saveError(cause: unknown) {
};
}
export const load: PageServerLoad = ({ locals, params }) => {
export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv');
return {
workspace: loadFundusWorkspace(url),
piece,
taxonomy: getTaxonomy(),
tools: listTools(true),
@@ -165,7 +167,7 @@ export const actions = {
return fail(problem.status, { intent: 'save', ...problem });
}
},
loadPreview: async ({ locals, params, request }) => {
loadPreview: async ({ locals, params, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
try {
@@ -188,7 +190,7 @@ export const actions = {
const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem });
}
redirect(303, `/fundus/${params.id}`);
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
},
saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user);
@@ -209,7 +211,7 @@ export const actions = {
return fail(problem.status, { intent: 'saveTemplate', ...problem });
}
},
upload: async ({ locals, params, request }) => {
upload: async ({ locals, params, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
const image = data.get('image');
@@ -239,9 +241,9 @@ export const actions = {
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
}
redirect(303, `/fundus/${params.id}`);
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
},
deleteImage: async ({ locals, params, request }) => {
deleteImage: async ({ locals, params, request, url }) => {
requireUser(locals.user);
const data = await request.formData();
try {
@@ -249,9 +251,9 @@ export const actions = {
} catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
}
redirect(303, `/fundus/${params.id}`);
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
},
duplicate: async ({ locals, params }) => {
duplicate: async ({ locals, params, url }) => {
requireUser(locals.user);
let id: string;
try {
@@ -259,7 +261,7 @@ export const actions = {
} catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
}
redirect(303, `/fundus/${id}`);
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
},
archive: async ({ locals, params, request }) => {
requireUser(locals.user);
+47 -25
View File
@@ -2,10 +2,12 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import RichMarkdownField from '#lib/components/RichMarkdownField.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
@@ -139,10 +141,12 @@
}
function requestClose() {
const target = resolve('/fundus');
const target = `${resolve('/fundus')}${page.url.search}`;
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
allowNavigation = true;
// The target combines the resolved Fundus route with the current filter query.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(target);
return;
}
@@ -152,6 +156,8 @@
return;
}
allowNavigation = true;
// The target combines the resolved Fundus route with the current filter query.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(target);
}
@@ -246,9 +252,15 @@
};
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:head><title>{draft.title} | Pieces | Sendewerk</title></svelte:head>
<div class="piece-editor-route">
<div class="fundus-workspace fundus-editor-workspace">
<FundusNavigation
filters={data.workspace.filters}
counts={data.workspace.counts}
topics={data.workspace.taxonomy.topics}
/>
<div class="piece-editor-route">
{#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error}
</div>{/if}
@@ -257,17 +269,20 @@
bind:activeSection
labelledby="piece-editor-title"
onrequestclose={requestClose}
inline
>
{#snippet header()}
<div class="piece-editor-kicker">
<p class="eyebrow">Fundusfassung</p>
<p class="eyebrow">Piece-Fassung</p>
<form method="POST" action="?/favorite" use:enhance>
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
<button
class="piece-favorite"
class:active={data.piece.isFavorite}
type="submit"
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
aria-label={data.piece.isFavorite
? 'Aus Favoriten entfernen'
: 'Als Favorit markieren'}
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
>
</form>
@@ -292,42 +307,42 @@
>
<input type="hidden" name="revision" value={revision} />
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<section class="piece-editor-panel piece-script-panel">
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2>
<h2>Streamtext</h2>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
<RichMarkdownField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro}
/>
<MarkdownEditorField
<RichMarkdownField
name="moderationNotes"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderationNotes}
/>
<MarkdownEditorField
<RichMarkdownField
name="interactionText"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interactionText}
/>
<MarkdownEditorField
<RichMarkdownField
name="transitionText"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText}
/>
</div>
<details class="piece-meta-details">
<details class="piece-meta-details" open>
<summary>
<span>Status, Dauer und Einordnung</span>
<small
@@ -405,7 +420,7 @@
</details>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<section class="piece-editor-panel piece-research-panel">
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
@@ -413,7 +428,7 @@
</div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<MarkdownEditorField
<RichMarkdownField
name="researchText"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -524,7 +539,7 @@
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<section class="piece-editor-panel piece-presentation-panel">
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
@@ -559,7 +574,7 @@
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
<RichMarkdownField
name="presentationText"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
@@ -617,7 +632,8 @@
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
oninput={() => (draft.presentationDescriptionManual = true)}
></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
@@ -645,7 +661,11 @@
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
@@ -684,8 +704,7 @@
<section
class="piece-editor-panel piece-media-panel"
hidden={activeSection !== 'presentation' ||
!['text', 'image'].includes(draft.presentationMode)}
hidden={!['text', 'image'].includes(draft.presentationMode)}
>
<div class="piece-subsection-heading">
<div>
@@ -727,8 +746,10 @@
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
/><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Entfernen</button
>
</form>
</figcaption>
@@ -793,7 +814,7 @@
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
!confirm('Piece löschen? Bestehende Streamfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
@@ -838,4 +859,5 @@
>
<input type="hidden" name="revision" value={revision} />
</form>
</div>
</div>
+8 -8
View File
@@ -49,7 +49,7 @@ function actionError(cause: unknown) {
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NOT_STARTED')
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
return { status: 409, error: 'Der Stream muss vor der Auswahl gestartet werden.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
@@ -57,16 +57,16 @@ function actionError(cause: unknown) {
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
return { status: 409, error: 'Abgeschlossene Streams können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
return { status: 404, error: 'Stream oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Der Stream ist pausiert.' };
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
if (code === 'NO_SECTION_TIMER')
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
return { status: 409, error: 'Der Stream befindet sich nicht im passenden Zustand.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
}
@@ -83,7 +83,7 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, url }) => {
const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
if (!showId) error(404, 'Kein Stream vorhanden');
try {
const live = getLiveView(showId);
return {
@@ -97,7 +97,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
};
} catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden');
error(404, 'Stream nicht gefunden');
throw cause;
}
};
@@ -210,7 +210,7 @@ export const actions = {
const data = await request.formData();
const status = text(data, 'status');
if (status !== 'draft' && status !== 'ready') {
return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' });
return fail(400, { intent: 'setStatus', error: 'Der Streamstatus ist ungültig.' });
}
return attempt('setStatus', () =>
setShowStatus(
+22 -22
View File
@@ -74,10 +74,10 @@
)
);
let currentSectionTitle = $derived(
currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')
currentSection?.name ?? (sessionComplete ? 'Streamende' : 'Live-Regie')
);
let nextTitle = $derived(
data.live.next?.title ?? (sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')
data.live.next?.title ?? (sessionComplete ? 'Streamende erreicht' : 'Kein weiteres Piece')
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
@@ -562,8 +562,8 @@
class:paused={data.live.isPaused}
class="live-timer-toggle"
type="submit"
aria-label={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
title={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
aria-label={data.live.isPaused ? 'Stream fortsetzen' : 'Stream pausieren'}
title={data.live.isPaused ? 'Stream fortsetzen' : 'Stream pausieren'}
>
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
</button>
@@ -613,10 +613,10 @@
inert={mobileRailOpen}
>
<section class="live-menu-show">
<strong>Sendung steuern</strong>
<strong>Stream steuern</strong>
<div class="live-menu-show-heading">
<span>
<small>Sendung</small>
<small>Stream</small>
<strong>{data.live.show.title}</strong>
</span>
<span class:paused={data.live.isPaused} class="live-show-state">{showStateLabel()}</span>
@@ -627,7 +627,7 @@
<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
><AppIcon name="play" /> Stream starten</button
>
</form>
<form method="POST" action={liveAction('setStatus')} use:enhance={enhanceCommand}>
@@ -646,7 +646,7 @@
</form>
{:else if data.live.endedAtUtc}
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen.</p>
<p class="quiet-copy">Dieser Stream ist historisch abgeschlossen.</p>
{:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
@@ -681,7 +681,7 @@
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
<AppIcon name="box-archive" /> Stream abschließen
</button>
{/if}
{:else}
@@ -703,7 +703,7 @@
action={liveAction('end')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Sendung wirklich beenden? Offene Pieces bleiben offen.'))
if (!confirm('Stream wirklich beenden? Offene Pieces bleiben offen.'))
event.preventDefault();
}}
>
@@ -711,7 +711,7 @@
<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
><AppIcon name="stop" /> Stream beenden</button
>
</form>
{/if}
@@ -753,9 +753,9 @@
<span><AppIcon name="display" /> Ausgabe einrichten</span>
<small
>{data.outputStatus === 'connected'
? 'Mit dieser Sendung verbunden'
? 'Mit diesem Stream verbunden'
: data.outputStatus === 'other_show'
? 'Mit einer anderen Sendung verbunden'
? 'Mit einem anderen Stream verbunden'
: 'Nicht verbunden'}</small
>
<AppIcon name="chevron-right" />
@@ -767,7 +767,7 @@
<button class="secondary-button" type="submit">
{data.outputStatus === 'connected'
? 'Ausgabe öffnen'
: 'Mit dieser Sendung verbinden und öffnen'}
: 'Mit diesem Stream verbinden und öffnen'}
</button>
</form>
{#if data.outputStatus === 'connected'}
@@ -794,7 +794,7 @@
onsubmit={(event) => {
if (
!confirm(
'Ausgabekanal trennen? Verbundene Browserquellen zeigen anschließend keine Sendung.'
'Ausgabekanal trennen? Verbundene Browserquellen zeigen anschließend keinen Stream.'
)
)
event.preventDefault();
@@ -810,7 +810,7 @@
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
><AppIcon name="arrow-left" /> Zur Planung</a
>
<small>Die Sendung und öffentliche Ausgabe laufen unverändert weiter.</small>
<small>Der Stream und die öffentliche Ausgabe laufen unverändert weiter.</small>
</nav>
</div>{/if}
@@ -825,7 +825,7 @@
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
<h2>Stream abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
@@ -838,7 +838,7 @@
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
<legend>Originale bewusst in den Piece-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
@@ -967,12 +967,12 @@
{sessionComplete
? 'Alle Pieces bearbeitet'
: data.live.totalCount
? 'Bereit für die Sendung'
? 'Bereit für den Stream'
: 'Noch kein Piece eingeplant'}
</h1>
<p>
{sessionComplete
? 'Der Live-Ablauf ist am Ende. Die Sendung wird hier nicht automatisch abgeschlossen.'
? 'Der Live-Ablauf ist am Ende. Der Stream wird hier nicht automatisch abgeschlossen.'
: data.live.totalCount
? 'Starte oder setze den gespeicherten Live-Stand fort.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
@@ -986,7 +986,7 @@
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Stream starten'}
</button>
</form>{/if}
</div>
@@ -1303,7 +1303,7 @@
? 'Erledigen & weiter'
: hasOtherOpenUsages
? 'Erledigen'
: 'Erledigen & Sendungsende'}
: 'Erledigen & Streamende'}
</button>
</form>
{/if}
+1 -1
View File
@@ -10,7 +10,7 @@
<h1>{data.clientName} verbinden?</h1>
<p>
Die Anwendung erhält im Namen von <strong>{data.user.displayName}</strong> lesenden Zugriff auf redaktionelle
Pieces, Sendungen und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und
Pieces, Streams und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und
die Reservebank.
</p>
<div class="oauth-permission">
+27 -2
View File
@@ -15,8 +15,33 @@ export const load: PageServerLoad = ({ locals, url }) => {
const today = new Date();
const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
const query = (url.searchParams.get('q') ?? '').trim();
const state = url.searchParams.get('state') ?? '';
const allShows = listShows();
const searchedShows = query
? allShows.filter((show) =>
show.title.toLocaleLowerCase('de-DE').includes(query.toLocaleLowerCase('de-DE'))
)
: allShows;
const shows = searchedShows.filter((show) => {
if (state === 'created') return show.status === 'draft' && show.sectionCount === 0;
if (state === 'preparing') return show.status === 'draft' && show.sectionCount > 0;
if (state === 'ready') return show.status === 'ready' || show.status === 'live';
if (state === 'completed') return show.status === 'completed';
return true;
});
return {
shows: listShows(['draft', 'ready', 'live']),
shows,
query,
state,
counts: {
all: allShows.length,
created: allShows.filter((show) => show.status === 'draft' && show.sectionCount === 0).length,
preparing: allShows.filter((show) => show.status === 'draft' && show.sectionCount > 0).length,
ready: allShows.filter((show) => show.status === 'ready' || show.status === 'live').length,
completed: allShows.filter((show) => show.status === 'completed').length
},
plannedShows: allShows.filter((show) => show.status !== 'completed').slice(0, 6),
preparationUsages: listPreparationUsages(),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
@@ -65,7 +90,7 @@ export const actions = {
});
}
return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.',
error: 'Der Stream konnte nicht angelegt werden.',
title,
startsAt,
templateId
+43 -20
View File
@@ -3,6 +3,8 @@
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { onMount } from 'svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
@@ -30,6 +32,11 @@
hour: '2-digit',
minute: '2-digit'
});
onMount(() => {
if (new URL(window.location.href).searchParams.get('create') === '1') {
document.querySelector<HTMLDetailsElement>('.show-create-panel')?.setAttribute('open', '');
}
});
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
@@ -57,21 +64,28 @@
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
}
function statusLabel(status: string) {
if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf';
function statusLabel(status: string, sectionCount = 1) {
if (status === 'ready') return 'Sendefertig';
if (status === 'draft') return sectionCount ? 'In Vorbereitung' : 'Angelegt';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<svelte:head><title>Streams | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page">
<div class="stream-workspace">
<StreamNavigation
query={data.query}
state={data.state}
counts={data.counts}
shows={data.plannedShows}
/>
<div class="page-wrap shows-page">
<header class="page-heading shows-heading">
<div>
<h1>Sendungen</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
<h1>Streams</h1>
<p class="page-intro">Streams aus Pieces und freien Abschnitten zusammenstellen.</p>
</div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} />
@@ -87,7 +101,7 @@
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<details class="show-create-panel">
<summary><AppIcon name="plus" /> Neue Sendung planen</summary>
<summary><AppIcon name="plus" /> Neuen Stream planen</summary>
<div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2>
@@ -99,7 +113,7 @@
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung"
placeholder="Nächster heinzlive-Stream"
/></label
>
<label
@@ -111,7 +125,7 @@
><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
name="newspaper"
/><span
><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span
><strong>Leer starten</strong><small>Einen leeren Stream frei aufbauen.</small></span
></label
>
{#each data.templates as template (template.id)}<label
@@ -125,7 +139,7 @@
></label
>{/each}
</fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button>
<button class="primary-button" type="submit">Stream anlegen</button>
</form>
</details>
@@ -151,7 +165,8 @@
/>{/each}</span
>
<span
><strong>{usage.title}</strong><small>{usage.showTitle} · {usage.sectionName}</small
><strong>{usage.title}</strong><small
>{usage.showTitle} · {usage.sectionName}</small
></span
>
</div>
@@ -169,7 +184,7 @@
<section class="show-library" aria-labelledby="show-library-title">
<header>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
<h2 id="show-library-title">{data.view === 'list' ? 'Streams' : 'Kalender'}</h2>
<span>{data.shows.length}</span>
</header>
@@ -180,7 +195,9 @@
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<span class={`show-state ${show.status}`}
>{statusLabel(show.status, show.sectionCount)}</span
>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
@@ -192,8 +209,8 @@
</div>
</a>{/each}
</div>{:else}<div class="empty-state compact">
<h2>Keine terminierten Sendungen</h2>
<p>Sendungen ohne Termin bleiben unten erreichbar.</p>
<h2>Keine terminierten Streams</h2>
<p>Streams ohne Termin bleiben unten erreichbar.</p>
</div>{/if}
{:else}
<div class="show-month-toolbar">
@@ -208,7 +225,8 @@
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
>
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}
>Heute</a
>
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
@@ -239,7 +257,7 @@
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span>
<small>{statusLabel(show.status)}</small>
<small>{statusLabel(show.status, show.sectionCount)}</small>
</a>{/each}
</div>{/if}
</section>{/each}
@@ -259,7 +277,9 @@
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span
><strong>{show.title}</strong><small
>{statusLabel(show.status, show.sectionCount)}</small
></span
>
</a>{/each}
</div>
@@ -284,7 +304,9 @@
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<span class={`show-state ${show.status}`}
>{statusLabel(show.status, show.sectionCount)}</span
>
<h3>{show.title}</h3>
<p>Termin offen</p>
</div>
@@ -294,4 +316,5 @@
</a>{/each}
</div>
</section>{/if}
</div>
</div>
+26 -7
View File
@@ -26,6 +26,7 @@ import {
removePieceUsage,
removeShowSection,
saveShowMetadata,
listShows,
updatePieceUsage,
updateShowSection,
type EditableShowStatus,
@@ -66,15 +67,15 @@ function actionError(cause: unknown) {
if (code === 'CONFLICT')
return {
status: 409,
error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.',
error: 'Der Stream wurde inzwischen geändert. Bitte neu laden.',
conflict: true
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' };
return { status: 409, error: 'Abgeschlossene Streams können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
return { status: 409, error: 'Das zugehörige Piece-Original ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
@@ -84,7 +85,7 @@ function actionError(cause: unknown) {
return {
status: 409,
error:
'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
'Diese Streamfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
};
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
@@ -110,8 +111,24 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, params, url }) => {
const actor = requireUser(locals.user);
const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden');
if (!show) error(404, 'Stream nicht gefunden');
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
const navigationShows = listShows();
const streamNavigation = {
query: '',
state: '',
counts: {
all: navigationShows.length,
created: navigationShows.filter((item) => item.status === 'draft' && item.sectionCount === 0)
.length,
preparing: navigationShows.filter((item) => item.status === 'draft' && item.sectionCount > 0)
.length,
ready: navigationShows.filter((item) => item.status === 'ready' || item.status === 'live')
.length,
completed: navigationShows.filter((item) => item.status === 'completed').length
},
shows: navigationShows.filter((item) => item.status !== 'completed').slice(0, 6)
};
if (show.status === 'completed') {
return {
show,
@@ -125,7 +142,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
taxonomy: getTaxonomy(),
pieceTemplates: [],
createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false }
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false },
streamNavigation
};
}
const pieces = listPieces({
@@ -154,7 +172,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
}
},
streamNavigation
};
};
+49 -30
View File
@@ -9,6 +9,7 @@
import AppIcon from '#lib/components/AppIcon.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -171,7 +172,7 @@
function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) {
if (!confirm('Piece aus diesem Stream entfernen?')) {
finishDrag();
return;
}
@@ -204,14 +205,16 @@
}
</script>
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head>
<svelte:head><title>{data.show.title} | Streams | Sendewerk</title></svelte:head>
{#if data.historical}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap historical-show-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a>
<p class="eyebrow">Unveränderliche Sendungshistorie</p>
<p class="eyebrow">Unveränderliche Streamhistorie</p>
<h1>{data.show.title}</h1>
<p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -224,7 +227,7 @@
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
<button class="secondary-button" type="submit">Als neuen Stream duplizieren</button>
</form>
</div>
</header>
@@ -300,11 +303,14 @@
</section>{/each}
</div>
</div>
</div>
{:else}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Streams</a
>
<h1>{data.show.title}</h1>
<p class="show-date">
@@ -337,7 +343,7 @@
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings">
<summary>Sendungsdaten und Twitch-Texte</summary>
<summary>Streamdaten und Twitch-Texte</summary>
<form
use:enhance={enhanceCommand}
method="POST"
@@ -385,7 +391,7 @@
>
<div class="wide-field settings-submit">
<span>{copyMessage}</span><button class="primary-button" type="submit"
>Sendungsdaten speichern</button
>Streamdaten speichern</button
>
</div>
</form>
@@ -402,7 +408,7 @@
class:active={mobilePane === 'fundus'}
aria-pressed={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
onclick={() => (mobilePane = 'fundus')}>Pieces</button
>
<button
class:active={mobilePane === 'plan'}
@@ -431,12 +437,12 @@
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Planbarer Fundus</h2>
<h2 id="source-title">Planbare Pieces</h2>
</div>
<span>{visiblePieces.length}</span>
</header>
{#if dropTarget === 'source'}<div class="drag-remove-target">
<strong>Aus Sendung entfernen</strong><span>Das Fundus-Original bleibt erhalten.</span>
<strong>Aus Stream entfernen</strong><span>Das Piece-Original bleibt erhalten.</span>
</div>{/if}
<details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary>
@@ -464,11 +470,11 @@
</details>
<form id="builder-filters" method="GET" class="builder-filter">
<label
><span class="sr-only">Fundus durchsuchen</span><input
><span class="sr-only">Pieces durchsuchen</span><input
type="search"
name="q"
value={data.filters.query}
placeholder="Fundus durchsuchen"
placeholder="Pieces durchsuchen"
/></label
>
<label
@@ -482,7 +488,7 @@
>
<button class="text-button" type="submit">Filtern</button>
</form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern">
<div class="builder-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
@@ -523,7 +529,8 @@
>
<div>
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
><i></i>{piece.categories.map((item) => item.name).join(', ') ||
'Offen'}</span
><span>{formatClock(piece.durationSeconds)}</span>
</div>
<h3>
@@ -535,7 +542,8 @@
{piece.format} · {piece.topics.map((item) => item.name).join(', ') ||
'Ohne Thema'}
</p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span
>{/if}
<PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div>
@@ -569,8 +577,8 @@
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2>
<p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
markieren.
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Bereich Pieces
sendefertig markieren.
</p>
</div>{/if}
</section>
@@ -582,7 +590,7 @@
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<p class="eyebrow">Streamplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
@@ -603,7 +611,10 @@
animate:flip={{ duration: 180 }}
>
{#if dropPreview?.kind === 'section' && dropPreview.position === index}
<div class="drag-insert-preview section-insert-preview" data-drop-preview="section">
<div
class="drag-insert-preview section-insert-preview"
data-drop-preview="section"
>
<span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div>
{/if}
@@ -791,7 +802,7 @@
dropAtPreview();
}}
>
Leerer Abschnitt · Piece aus dem Fundus hinzufügen oder hierher ziehen
Leerer Abschnitt · Piece hinzufügen oder hierher ziehen
</div>
{/if}
<details class="section-editor">
@@ -844,7 +855,8 @@
method="POST"
action="?/removeSection"
onsubmit={(event) =>
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
!confirm('Abschnitt samt Einplanungen entfernen?') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
@@ -878,12 +890,17 @@
class="add-section-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><label
>Name<input name="name" required maxlength="200" placeholder="Neuer Abschnitt" /></label
>Name<input
name="name"
required
maxlength="200"
placeholder="Neuer Abschnitt"
/></label
><label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}>{category.name}</option
>{/each}</select
></label
><label
@@ -959,7 +976,8 @@
ondragstart={(event) => startDrag('usage', usage.id, event)}
ondragend={finishDrag}
>
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small>
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small
>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input
@@ -971,8 +989,8 @@
name="sectionId"
required
><option value="">Abschnitt wählen</option
>{#each data.show.sections as section (section.id)}<option value={section.id}
>{section.name}</option
>{#each data.show.sections as section (section.id)}<option
value={section.id}>{section.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Einplanen</button>
@@ -982,7 +1000,7 @@
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
@@ -991,8 +1009,8 @@
/><button
class="text-button danger-text"
type="submit"
aria-label={`${usage.title} aus Sendung entfernen`}
>Aus Sendung entfernen</button
aria-label={`${usage.title} aus Stream entfernen`}
>Aus Stream entfernen</button
>
</form>
<UsageEditorDialog
@@ -1072,4 +1090,5 @@
<input type="hidden" name="usageId" value={removedUsageId} />
</form>
</div>
</div>
{/if}
+9
View File
@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { searchWorkspace } from '#lib/server/global-search.js';
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const query = (url.searchParams.get('q') ?? '').trim().slice(0, 200);
return { query, results: searchWorkspace(query) };
};
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
let { data } = $props();
const statusLabels: Record<string, string> = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Bereit',
archived: 'Archiviert',
draft: 'Angelegt',
live: 'Live',
completed: 'Abgeschlossen'
};
</script>
<svelte:head><title>Suche | Sendewerk</title></svelte:head>
<section class="page-wrap narrow-page search-page">
<header class="page-heading">
<p class="eyebrow">Arbeitsbereich</p>
<h1>Suche</h1>
<p>Durchsuche Pieces und Streams einschließlich abgeschlossener und archivierter Inhalte.</p>
</header>
<form class="search-page-form" method="GET" action={resolve('/suche')} role="search">
<label>
<span class="sr-only">Suchbegriff</span>
<AppIcon name="magnifying-glass" />
<input
name="q"
type="search"
value={data.query}
minlength="2"
placeholder="Titel, Recherche, Ablauf …"
/>
</label>
<button class="primary-button" type="submit">Suchen</button>
</form>
{#if data.query.length === 1}
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
{:else if data.query}
<div class="search-result-columns">
<section aria-labelledby="piece-results">
<header class="result-group-heading">
<h2 id="piece-results">Pieces</h2>
<span>{data.results.pieces.length}</span>
</header>
{#if data.results.pieces.length}
<div class="search-result-list">
{#each data.results.pieces as result (result.id)}
<!-- Search results contain only server-generated internal paths. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={result.href}>
<span class="result-title"
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
>{statusLabels[result.status] ?? result.status}</span
></span
>
<small>{result.matchLabel}</small>
<p>{result.excerpt}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine passenden Pieces.</p>{/if}
</section>
<section aria-labelledby="show-results">
<header class="result-group-heading">
<h2 id="show-results">Streams</h2>
<span>{data.results.shows.length}</span>
</header>
{#if data.results.shows.length}
<div class="search-result-list">
{#each data.results.shows as result (result.id)}
<!-- Search results contain only server-generated internal paths. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={result.href}>
<span class="result-title"
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
>{statusLabels[result.status] ?? result.status}</span
></span
>
<small>{result.matchLabel}</small>
<p>{result.excerpt}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine passenden Streams.</p>{/if}
</section>
</div>
{/if}
</section>
+1 -1
View File
@@ -275,7 +275,7 @@
</button>
</div>
<p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Streams erhalten.
</p>
<div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)}
+153 -105
View File
@@ -31,12 +31,14 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
}
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')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} 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');
}
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) {
await page.goto('/login');
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) {
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$/);
}
@@ -478,6 +495,7 @@ test.beforeAll(async ({ browser }) => {
expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/);
await page.locator('.app-header .account-menu > summary').click();
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await page.close();
@@ -487,6 +505,35 @@ test.beforeEach(async ({ 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 ({
page,
browser
@@ -566,16 +613,23 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Pieces', exact: true }).first()).toBeVisible();
await expect(page.locator('.app-header')).toBeVisible();
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);
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
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await page
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
.click();
@@ -587,6 +641,15 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await expect(page.locator('.piece-card').first()).toContainText(
'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
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
@@ -595,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();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const statusBox = await page.getByLabel('Status').boundingBox();
expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
expect(firstRow?.height ?? 999).toBeLessThan(150);
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('Pixelart');
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
@@ -615,11 +674,13 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
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 page.reload();
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 page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
});
@@ -634,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' })
).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page
.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();
await page.getByRole('link', { name: /Abgeschlossen/ }).click();
const usedPiece = page
.locator('.piece-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' });
@@ -658,9 +709,9 @@ test('sent and planned source filters stay independent and removing the last usa
await usedPiece.getByText('Vollständige Verwendungshistorie').click();
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked();
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('praktische Effekte');
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter');
await expect(page).toHaveURL(/view=completed/);
await expect(usedPiece).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'),
@@ -668,11 +719,11 @@ test('sent and planned source filters stay independent and removing the last usa
});
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.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Stream anlegen' }).click();
await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const consolePiece = page
.locator('.builder-source-card')
@@ -680,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 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.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(
@@ -693,7 +744,7 @@ test('sent and planned source filters stay independent and removing the last usa
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).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 expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await expect(
@@ -701,7 +752,7 @@ test('sent and planned source filters stay independent and removing the last usa
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await page.getByPlaceholder('Fundus durchsuchen').fill('');
await page.getByPlaceholder('Pieces durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck();
@@ -724,7 +775,7 @@ test('sent and planned source filters stay independent and removing the last usa
page.once('dialog', (dialog) => dialog.accept());
await page
.locator('.reserve-usage')
.getByRole('button', { name: /aus Sendung entfernen/ })
.getByRole('button', { name: /aus Stream entfernen/ })
.click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect(
@@ -733,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' })
).toBeVisible();
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 page.screenshot({
path: testInfo.outputPath('sendungsfilter-mobile.png'),
@@ -768,7 +819,9 @@ test('show overview switches persistently between list, calendar and mobile agen
await page.getByRole('link', { name: 'Nächster Monat' }).click();
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 expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute(
'aria-pressed',
@@ -787,7 +840,7 @@ test('show overview switches persistently between list, calendar and mobile agen
});
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 {
removeCalendarFixture();
}
@@ -818,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.getByLabel('Videotitel')).toBeVisible();
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.getByLabel('Vorbereitungsstatus').selectOption('ready');
await editor.getByRole('button', { name: 'Verwendung speichern' }).click();
@@ -856,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 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-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
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('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
@@ -881,7 +936,7 @@ test('mobile and tablet navigation do not obscure switchable content', async ({
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
@@ -906,10 +961,10 @@ test('a show can be planned from a template with independent usages and reserve'
test.slow();
await page.setViewportSize({ width: 1440, height: 1000 });
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.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-]+$/);
const showUrl = page.url();
await expect(page.locator('.run-section')).toHaveCount(5);
@@ -929,7 +984,7 @@ test('a show can be planned from a template with independent usages and reserve'
await sectionTransfer.dispose();
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();
const source = page
.locator('.builder-source-card')
@@ -968,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'
})
.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 expectNoHorizontalOverflow(page);
await page.screenshot({
@@ -977,7 +1032,7 @@ test('a show can be planned from a template with independent usages and reserve'
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Aus Fundus aktualisieren' })
.getByRole('button', { name: 'Aus Piece aktualisieren' })
.click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
usages = page
@@ -1056,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'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect(page.locator('dialog[open]')).toContainText('Streamfassung');
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible();
@@ -1093,7 +1148,7 @@ test('a show can be planned from a template with independent usages and reserve'
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.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();
const duplicatedUsage = page
.locator('.editable-usage')
@@ -1104,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' })
.click();
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'
);
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
@@ -1121,10 +1176,10 @@ test('a show can be planned from a template with independent usages and reserve'
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Aus Sendung entfernen' })
.getByRole('button', { name: 'Aus Stream entfernen' })
.scrollIntoViewIfNeeded();
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' })
page.locator('dialog[open]').getByRole('button', { name: 'Aus Stream entfernen' })
).toBeVisible();
await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
@@ -1392,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.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.getByText('Werkzeuge auswählen', { exact: true }).click();
const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ });
await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible();
@@ -1424,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.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('Deaktiviert');
await expect(page.getByLabel('Arbeitslink')).toHaveValue(
@@ -1460,7 +1513,7 @@ test('validated YouTube and TikTok players switch without exposing private field
await page.getByText('Menü', { exact: true }).click();
await page.getByText('Ausgabe einrichten', { exact: true }).click();
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Mit dieser Sendung verbinden und öffnen' }).click();
await page.getByRole('button', { name: 'Mit diesem Stream verbinden und öffnen' }).click();
const output = await popupPromise;
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
@@ -1510,8 +1563,9 @@ test('live control persists state and exposes only explicit presentation data',
setSectionDuration('section-stories', 300);
await page.setViewportSize({ width: 1280, height: 900 });
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.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page
.getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true })
.click();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
@@ -1523,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.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 expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
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 page.getByRole('radio', { name: 'Text', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
@@ -1537,14 +1589,14 @@ test('live control persists state and exposes only explicit presentation data',
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.first();
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 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('.mobile-header')).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.getByRole('button', { name: 'Zurück', exact: true })).toBeDisabled();
await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/);
@@ -1562,14 +1614,14 @@ test('live control persists state and exposes only explicit presentation data',
await page.keyboard.press('Escape');
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await page.getByRole('button', { name: 'Sendung pausieren' }).click();
await expect(page.getByRole('button', { name: 'Sendung fortsetzen' })).toBeVisible();
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: 'Sendung fortsetzen' }).click();
await expect(page.getByRole('button', { name: 'Sendung pausieren' })).toBeVisible();
await page.getByRole('button', { name: 'Stream fortsetzen' }).click();
await expect(page.getByRole('button', { name: 'Stream pausieren' })).toBeVisible();
await liveMenuTrigger.press('Enter');
await expect(liveMenu).toBeVisible();
await page.locator('.live-current').click({ position: { x: 8, y: 8 } });
@@ -1600,7 +1652,7 @@ test('live control persists state and exposes only explicit presentation data',
await page.getByText('Ausgabe einrichten', { exact: true }).click();
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Mit dieser Sendung verbinden und öffnen' }).click()
page.getByRole('button', { name: 'Mit diesem Stream verbinden und öffnen' }).click()
]);
await popup.waitForURL(/\/heinzlive\/default#/);
const outputUrl = popup.url();
@@ -1899,7 +1951,7 @@ test('live control persists state and exposes only explicit presentation data',
'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.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
@@ -1927,7 +1979,7 @@ test('live control persists state and exposes only explicit presentation data',
).toBeVisible();
await page.getByText('Menü', { exact: true }).click();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Sendung beenden' }).click();
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();
@@ -1961,7 +2013,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage();
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('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage
@@ -1998,7 +2050,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
await page.goto('/live?show=e2e-completion');
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');
await expect(completion).toBeVisible();
await expect(completion).toContainText('1 gesendet');
@@ -2008,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 expect(completion).toBeHidden();
await page.goto('/archiv');
await expect(page.getByRole('link', { name: 'E2E Abschluss' })).toBeVisible();
await page.getByRole('link', { name: 'E2E Abschluss' }).click();
await expect(page.getByText('Unveränderliche Sendungshistorie')).toBeVisible();
await page.goto('/sendungen?state=completed');
await expect(
page.locator('.show-card-list').getByRole('link', { name: /E2E Abschluss/ })
).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('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Streamdaten speichern' })).toHaveCount(0);
expect(
await page
.locator('.historical-section')
@@ -2040,7 +2097,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
}));
expect(dialogStyle.borderRadius).toBeGreaterThan(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('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
@@ -2058,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('e2e-completion');
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 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.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
@@ -2125,20 +2182,24 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
page,
browser
}, testInfo) => {
test.setTimeout(45_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus');
await page.locator('.template-picker > summary').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
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 expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Editor schließen' }).click();
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 page.getByText('Status, Dauer und Einordnung').click();
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
@@ -2147,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.getByLabel('Gaming').check();
await openEditorialField(page, 'Private Recherche');
await page
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Nur intern: **Hintergrund prüfen**.');
await fillMarkdownSource(page, 'Private Recherche', 'Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Den Kern in zwei Sätzen erklären.');
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**.'
);
await openEditorialField(page, 'Präsentationstext');
await page
.getByRole('textbox', { name: 'Öffentlicher Vorspann', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await fillMarkdownSource(
page,
'Öffentlicher Vorspann',
'## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'
);
await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last();
@@ -2178,10 +2239,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(
page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' })
).toBeVisible();
await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
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**.'
);
@@ -2189,7 +2249,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png',
mimeType: 'image/png',
@@ -2212,7 +2271,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await concurrent.goto(pieceUrl);
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 concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -2223,7 +2281,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(
concurrent.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' })
).toBeVisible();
await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
@@ -2234,7 +2291,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).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 expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
@@ -2267,7 +2324,6 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
@@ -2291,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 expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
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(
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
await expect(page.getByLabel('Status')).toBeVisible();
await openEditorialField(page, 'Private Recherche');
await page
@@ -2306,7 +2361,6 @@ test('a quickly captured video can be written and previewed before classificatio
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.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('combobox', { name: 'Video-URL', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
@@ -2321,9 +2375,7 @@ test('a quickly captured video can be written and previewed before classificatio
await preview.close();
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 page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
@@ -2332,7 +2384,6 @@ test('link preview uses one existing source, persists manual fields and isolates
page
}, testInfo) => {
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('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
@@ -2340,7 +2391,6 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.getByLabel('Vorschaubild-URL').fill('');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
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.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau');
@@ -2366,10 +2416,8 @@ test('link preview uses one existing source, persists manual fields and isolates
});
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 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) =>
response.url().includes('?/loadPreview')
);