diff --git a/CloudronManifest.json b/CloudronManifest.json index 77c87ad..a1f09c1 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -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": { diff --git a/README.md b/README.md index 3c32fa2..2447cd8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9d0ca4f..d4665cb 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e905dca..4608352 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4d55189..4d8e28f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -570,3 +570,53 @@ Quellen, geprüft am 27. Oktober 2026: - - - + +## 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: + +- +- +- 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. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 5a9e539..1000057 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index c18f34e..c71ec7b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 41e31d9..e2dd9c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 99edf0d..514aee1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.css b/src/app.css index 11c2b25..3e74c3c 100644 --- a/src/app.css +++ b/src/app.css @@ -3055,6 +3055,177 @@ textarea:focus { } } +.dashboard-page { + max-width: 90rem; +} + +.dashboard-heading, +.dashboard-quick-actions, +.dashboard-next-stream > header, +.dashboard-pieces > header, +.dashboard-side-stack section > header, +.dashboard-stream-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.dashboard-heading { + margin-bottom: 1rem; +} + +.dashboard-heading h1, +.dashboard-next-stream h2, +.dashboard-pieces h2, +.dashboard-side-stack h2 { + margin: 0; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(17rem, 0.75fr); + gap: 1rem; +} + +.dashboard-next-stream, +.dashboard-side-stack section, +.dashboard-pieces { + padding: clamp(1rem, 2vw, 1.4rem); + border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line)); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--glass) 92%, transparent); + box-shadow: inset 0 1px rgb(255 255 255 / 7%); +} + +.dashboard-next-stream > header p { + margin: 0.35rem 0 0; + color: var(--text-muted); +} + +.dashboard-run-list { + display: grid; + gap: 0.2rem; + margin: 1.2rem 0; +} + +.dashboard-run-section { + display: grid; + grid-template-columns: 0.28rem minmax(0, 1fr); + gap: 0.8rem; + padding: 0.72rem 0; + border-bottom: 1px solid var(--line); +} + +.run-marker { + border-radius: 999px; + background: var(--marker); +} + +.dashboard-run-section > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.25rem 0.75rem; +} + +.dashboard-run-section p { + grid-column: 1 / -1; + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; +} + +.dashboard-run-section small { + color: var(--text-faint); +} + +.dashboard-side-stack, +.dashboard-mini-list { + display: grid; + gap: 0.65rem; +} + +.dashboard-side-stack section > header { + margin-bottom: 0.8rem; +} + +.dashboard-mini-list a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.7rem 0; + border-bottom: 1px solid var(--line); +} + +.dashboard-mini-list a > span { + display: grid; + gap: 0.25rem; +} + +.dashboard-mini-list small { + color: var(--text-muted); +} + +.dashboard-pieces { + margin-top: 1rem; +} + +.dashboard-pieces > header { + margin-bottom: 1rem; +} + +.dashboard-piece-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; +} + +.dashboard-piece-grid a { + display: grid; + align-content: start; + gap: 0.45rem; + min-height: 8.5rem; + padding: 0.9rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel); +} + +.dashboard-piece-grid a:hover { + border-color: color-mix(in srgb, var(--gold) 50%, var(--line)); +} + +.dashboard-piece-grid p { + display: -webkit-box; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--text-muted); + font-size: 0.82rem; +} + +@media (max-width: 62rem) { + .dashboard-grid, + .dashboard-piece-grid { + grid-template-columns: 1fr; + } + .dashboard-heading { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 34rem) { + .dashboard-quick-actions, + .dashboard-stream-actions { + width: 100%; + align-items: stretch; + flex-direction: column; + } +} + .quiet-panel { border: 1px solid var(--line); border-radius: var(--radius-large); @@ -5484,6 +5655,21 @@ textarea:focus { display: grid; } +@media (min-width: 52.01rem) { + .piece-editor-dialog.piece-editor-side-panel { + position: fixed; + inset: 4.25rem 0 0 auto; + z-index: 25; + width: min(35rem, 42vw); + height: calc(100dvh - 4.25rem); + margin: 0; + border-block: 0; + border-right: 0; + border-radius: var(--radius-large) 0 0 0; + box-shadow: -18px 0 48px rgb(0 0 0 / 22%); + } +} + .piece-editor-dialog::backdrop { background: color-mix(in srgb, var(--bg) 46%, transparent); -webkit-backdrop-filter: blur(6px) saturate(0.95); @@ -7125,6 +7311,887 @@ textarea:focus { } } +/* Horizontal application chrome and the three-pane Fundus workspace. */ +.app-header { + position: fixed; + inset: 0 0 auto; + z-index: 30; + display: grid; + height: 4.25rem; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: clamp(1rem, 3vw, 3rem); + padding: 0 clamp(1rem, 2vw, 2rem); + border-bottom: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line)); + background: var(--glass); + box-shadow: 0 10px 34px rgb(0 0 0 / 12%); + -webkit-backdrop-filter: blur(24px) saturate(1.25); + backdrop-filter: blur(24px) saturate(1.25); +} + +.app-header .main-navigation { + display: flex; + justify-content: center; + gap: 0.3rem; + margin: 0; +} + +.app-header .main-navigation a { + min-height: 2.5rem; + padding-inline: 0.85rem; +} + +.app-header-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.global-search-form label { + display: flex; + min-width: 13rem; + align-items: center; + gap: 0.5rem; + padding: 0.38rem 0.7rem; + border: 1px solid var(--line); + border-radius: 999px; + background: color-mix(in srgb, var(--panel-strong) 72%, transparent); + color: var(--text-muted); +} + +.global-search-form input { + min-width: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + box-shadow: none; + color: var(--text); +} + +.global-search-form input::placeholder { + color: var(--text-faint); +} + +.app-header-actions .theme-toggle { + width: auto; + padding: 0.45rem; + border: 0; +} + +.app-header-actions .theme-toggle > span:last-child { + display: none; +} + +.header-utility-link, +.account-menu > summary { + display: grid; + width: 2.35rem; + height: 2.35rem; + place-items: center; + border: 1px solid var(--line); + border-radius: 999px; + background: color-mix(in srgb, var(--panel-strong) 72%, transparent); + color: var(--text-muted); + cursor: pointer; + list-style: none; +} + +.header-utility-link.active { + border-color: color-mix(in srgb, var(--gold) 52%, var(--line)); + color: var(--gold-strong); +} + +.account-menu { + position: relative; +} + +.account-menu > div { + position: absolute; + top: calc(100% + 0.55rem); + right: 0; + display: grid; + min-width: 12rem; + gap: 0.25rem; + padding: 0.8rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-medium); + background: var(--glass-strong); + box-shadow: var(--shadow); + -webkit-backdrop-filter: blur(22px); + backdrop-filter: blur(22px); +} + +.user-menu-popover header { + display: grid; + padding: 0.2rem 0.35rem 0.65rem; + border-bottom: 1px solid var(--line); +} + +.user-menu-popover fieldset { + margin: 0; + padding: 0.65rem 0 0.35rem; + border: 0; +} + +.user-menu-popover legend { + padding: 0 0.35rem 0.4rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 680; +} + +.user-menu-link { + display: flex; + width: 100%; + align-items: center; + gap: 0.55rem; + padding: 0.6rem; + border: 0; + border-radius: 0.65rem; + background: transparent; + color: var(--text); + cursor: pointer; + font: inherit; + text-align: left; +} + +.user-menu-link:hover { + background: var(--panel); +} + +.theme-menu-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.3rem; +} + +.theme-menu-options button { + display: grid; + min-width: 3.4rem; + gap: 0.25rem; + place-items: center; + padding: 0.5rem 0.35rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 0.72rem; +} + +.theme-menu-options button.active { + border-color: color-mix(in srgb, var(--gold) 55%, var(--line)); + background: color-mix(in srgb, var(--gold) 12%, var(--panel)); + color: var(--text); +} + +.mobile-global-search { + flex: 1; + max-width: 15rem; + margin-left: auto; +} + +.mobile-global-search label { + min-width: 0; +} + +.search-page-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.65rem; + margin: 1.5rem 0 2rem; +} + +.search-page-form label { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0 0.85rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-medium); + background: var(--panel); + color: var(--text-muted); +} + +.search-page-form input { + width: 100%; + border: 0; + background: transparent; + box-shadow: none; +} + +.search-result-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.result-group-heading, +.result-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.result-group-heading { + margin-bottom: 0.65rem; +} + +.result-group-heading h2 { + margin: 0; +} + +.result-group-heading > span { + display: grid; + min-width: 1.7rem; + height: 1.7rem; + place-items: center; + border-radius: 999px; + background: var(--panel); + color: var(--text-muted); + font-size: 0.75rem; +} + +.search-result-list { + display: grid; + gap: 0.55rem; +} + +.search-result-list > a { + display: grid; + gap: 0.35rem; + padding: 0.9rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--glass) 90%, transparent); +} + +.search-result-list > a:hover { + border-color: color-mix(in srgb, var(--gold) 50%, var(--line)); + background: var(--panel); +} + +.search-result-list small { + color: var(--gold-strong); + font-weight: 680; +} + +.search-result-list p { + margin: 0; + color: var(--text-muted); + font-size: 0.84rem; + line-height: 1.45; +} + +@media (max-width: 52rem) { + .search-result-columns { + grid-template-columns: 1fr; + } + .mobile-header .brand > span:last-child { + display: none; + } + .mobile-global-search input { + font-size: 0.82rem; + } +} + +.account-menu small { + color: var(--text-muted); +} + +.app-shell > #main-content { + margin-left: 0; + padding-top: 4.25rem; +} + +.fundus-workspace { + display: grid; + width: 100%; + min-height: calc(100dvh - 4.25rem); + grid-template-columns: minmax(13rem, 16rem) minmax(28rem, 1fr) minmax(19rem, 25rem); + gap: 0.85rem; + padding: 0.85rem; +} + +.fundus-list-workspace { + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); +} + +.fundus-workspace:has(.fundus-navigation.collapsed) { + grid-template-columns: 4.1rem minmax(0, 1fr) minmax(19rem, 25rem); +} + +.fundus-list-workspace:has(.fundus-navigation.collapsed) { + grid-template-columns: 4.1rem minmax(0, 1fr); +} + +.stream-workspace { + display: grid; + min-height: calc(100dvh - 4.25rem); + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); + gap: 0.85rem; + padding: 0.85rem; +} + +.stream-workspace:has(.stream-navigation.collapsed) { + grid-template-columns: 4.1rem minmax(0, 1fr); +} + +.stream-workspace > .shows-page { + width: 100%; + padding-top: 1rem; +} + +.stream-detail-workspace { + display: grid; + min-height: calc(100dvh - 4.25rem); + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); + gap: 0.85rem; + padding: 0.85rem; +} + +.stream-detail-workspace:has(.stream-navigation.collapsed) { + grid-template-columns: 4.1rem minmax(0, 1fr); +} + +.stream-detail-workspace > .page-wrap { + width: 100%; + padding-top: 1rem; +} + +.fundus-navigation, +.fundus-list-pane, +.fundus-context-pane, +.fundus-editor-surface { + min-width: 0; + border: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line)); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--glass) 92%, transparent); + box-shadow: inset 0 1px rgb(255 255 255 / 7%); +} + +.fundus-navigation { + position: sticky; + top: 5.1rem; + height: calc(100dvh - 5.95rem); + padding: 1rem 0.7rem; + overflow-y: auto; +} + +.fundus-navigation header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + padding: 0.25rem 0.55rem 0.8rem; +} + +.fundus-navigation header > button, +.collapsed-search-button { + display: grid; + width: 2rem; + height: 2rem; + flex: none; + place-items: center; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.context-search { + margin-bottom: 0.75rem; +} + +.context-search label { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.55rem; + border: 1px solid var(--line); + border-radius: 0.68rem; + background: var(--material-inset); + color: var(--text-muted); +} + +.context-search input { + min-width: 0; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + font-size: 0.76rem; +} + +.fundus-navigation.collapsed { + padding-inline: 0.55rem; + overflow: hidden; +} + +.fundus-navigation.collapsed header { + justify-content: center; + padding-inline: 0; +} + +.fundus-navigation.collapsed header > div, +.fundus-navigation.collapsed nav a span, +.fundus-navigation.collapsed nav a strong, +.fundus-navigation.collapsed section h2 { + display: none; +} + +.fundus-navigation.collapsed nav a { + grid-template-columns: 1fr; + place-items: center; +} + +.collapsed-search-button { + width: 100%; + margin-bottom: 0.5rem; +} + +.piece-card { + position: relative; +} + +.piece-row-link { + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; +} + +.piece-card form, +.piece-card details, +.piece-card .piece-primary h2 a { + position: relative; + z-index: 2; +} + +.piece-card .piece-primary { + position: relative; + z-index: 2; + pointer-events: none; +} + +.piece-card .piece-primary h2 a { + pointer-events: auto; +} + +.piece-card .piece-primary details { + pointer-events: auto; +} + +.fundus-navigation h1 { + margin: 0.12rem 0 0; + font-size: 1.45rem; +} + +.fundus-navigation h2 { + margin: 1.15rem 0.55rem 0.45rem; + color: var(--text-faint); + font-size: 0.64rem; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.fundus-navigation nav { + display: grid; + gap: 0.16rem; +} + +.fundus-navigation nav a { + display: grid; + grid-template-columns: 1.2rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.55rem; + min-height: 2.25rem; + padding: 0.42rem 0.55rem; + border: 1px solid transparent; + border-radius: 0.68rem; + color: var(--text-muted); + font-size: 0.73rem; + font-weight: 650; +} + +.fundus-navigation nav a strong { + color: var(--text-faint); + font-size: 0.64rem; +} + +.fundus-navigation nav a:hover, +.fundus-navigation nav a.active { + border-color: color-mix(in srgb, var(--gold) 38%, var(--line)); + background: color-mix(in srgb, var(--gold) 10%, var(--material-inset)); + color: var(--text); +} + +.fundus-list-pane { + padding: clamp(1rem, 2vw, 1.4rem); +} + +.fundus-list-pane .page-heading h1 { + font-size: clamp(1.45rem, 2vw, 1.9rem); +} + +.fundus-list-pane .piece-grid { + gap: 0.45rem; +} + +.fundus-list-pane .piece-card { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.75rem; + padding: 0.7rem; +} + +.fundus-list-pane .piece-thumbnail { + grid-column: 1; + grid-row: 1 / 3; +} + +.fundus-list-pane .piece-primary { + grid-column: 2; + grid-row: 1; +} + +.fundus-list-pane .piece-classification, +.fundus-list-pane .piece-state { + grid-column: 2; +} + +.fundus-list-pane .piece-classification { + grid-row: 2; +} + +.fundus-list-pane .piece-state { + grid-column: 3; + grid-row: 2; +} + +.fundus-list-pane .piece-actions { + grid-column: 3; + grid-row: 1; +} + +.fundus-context-pane { + position: sticky; + top: 5.1rem; + height: fit-content; + padding: 1.15rem; +} + +.fundus-context-pane h2 { + margin: 0.25rem 0 0.55rem; +} + +.fundus-context-pane > p:not(.eyebrow) { + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.5; +} + +.fundus-overview-counts { + display: grid; + gap: 0.35rem; + margin: 1rem 0; +} + +.fundus-overview-counts div { + display: flex; + justify-content: space-between; + padding: 0.55rem 0; + border-bottom: 1px solid var(--line); +} + +.fundus-overview-counts dt, +.fundus-overview-counts dd { + margin: 0; + font-size: 0.74rem; +} + +.fundus-overview-counts dd { + color: var(--gold-strong); + font-weight: 760; +} + +.fundus-context-hint { + display: flex; + align-items: flex-start; + gap: 0.55rem; + padding-top: 0.8rem; +} + +.fundus-editor-workspace .piece-editor-route { + grid-column: 2 / 4; + min-width: 0; +} + +.fundus-editor-surface { + display: grid; + width: 100%; + height: calc(100dvh - 5.95rem); + max-width: none; + max-height: none; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.fundus-editor-surface .piece-dialog-workspace { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(20rem, 0.85fr); + grid-auto-rows: max-content; + align-content: start; + gap: 1rem; + padding: 1rem; +} + +.fundus-editor-surface .piece-dialog-form, +.fundus-editor-surface .piece-script-panel { + display: contents; +} + +.fundus-editor-surface .piece-script-panel > .piece-panel-heading, +.fundus-editor-surface .piece-script-panel > .piece-writing-stack { + grid-column: 1; +} + +.fundus-editor-surface .piece-script-panel > .piece-panel-heading { + grid-row: 1; +} + +.fundus-editor-surface .piece-script-panel > .piece-writing-stack { + grid-row: 2; +} + +.fundus-editor-surface .piece-script-panel > .piece-meta-details { + grid-column: 2; + grid-row: 1; + margin-top: 0; +} + +.fundus-editor-surface .piece-research-panel { + grid-column: 1; + grid-row: 3 / span 2; + width: 100%; +} + +.fundus-editor-surface .piece-presentation-panel { + grid-column: 2; + grid-row: 2 / span 2; + width: 100%; +} + +.fundus-editor-surface .piece-media-panel { + grid-column: 2; + grid-row: 4; + width: 100%; + margin-top: 0; +} + +.fundus-editor-surface .piece-presentation-layout, +.fundus-editor-surface .piece-meta-grid { + grid-template-columns: 1fr; +} + +.fundus-editor-surface .piece-meta-grid .wide-field, +.fundus-editor-surface .piece-meta-grid fieldset { + grid-column: auto; +} + +.fundus-editor-surface .piece-presentation-preview > .output-content { + min-height: 15rem; +} + +.rich-markdown-surface { + min-height: 5.5rem; + padding: 0.75rem 0.8rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + background: var(--field); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 5%); + font-size: 0.88rem; + line-height: 1.55; +} + +.rich-markdown-field > [hidden] { + display: none !important; +} + +.rich-markdown-content { + position: relative; + min-height: 4rem; + outline: 0; +} + +.rich-markdown-content > :first-child { + margin-top: 0; +} + +.rich-markdown-content > :last-child { + margin-bottom: 0; +} + +.rich-markdown-content:empty::before, +.rich-markdown-content:has(> p:only-child > br.ProseMirror-trailingBreak)::before { + position: absolute; + inset: 0 auto auto 0; + color: var(--text-faint); + content: attr(data-placeholder); + pointer-events: none; +} + +.fundus-editor-surface .piece-presentation-controls { + padding: 0.8rem; + border: 1px solid color-mix(in srgb, #e7b957 32%, #30435c); + border-radius: var(--radius-medium); + background: #0b1220; + color: #fff7e8; +} + +.fundus-editor-surface .piece-presentation-controls :is(.field-label, label) { + color: #a9b6c8; +} + +.fundus-editor-surface + .piece-presentation-controls + :is(input:not([type='radio']), textarea, select), +.fundus-editor-surface .piece-presentation-controls .rich-markdown-surface { + border-color: #30435c; + background: #152238; + color: #fff7e8; +} + +.fundus-editor-surface .piece-presentation-controls > label:has(input[name='outputTitle']) input { + font-size: 1.35rem; + font-weight: 760; + letter-spacing: -0.025em; +} + +.fundus-editor-surface + .piece-presentation-panel:has(input[value='text']:checked) + .piece-presentation-preview { + display: none; +} + +.markdown-toolbar button.active { + border-color: color-mix(in srgb, var(--gold) 55%, var(--line)); + background: color-mix(in srgb, var(--gold) 12%, var(--material-inset)); + color: var(--text); +} + +.markdown-source-warning { + margin: 0 0 0.45rem; + color: var(--gold-strong); + font-size: 0.68rem; +} + +@media (max-width: 78rem) { + .fundus-workspace { + grid-template-columns: minmax(12rem, 14rem) minmax(0, 1fr); + } + + .fundus-context-pane { + display: none; + } + + .fundus-editor-workspace .piece-editor-route { + grid-column: 2; + } +} + +@media (max-width: 52rem) { + .app-header { + display: none; + } + + .app-shell > #main-content { + padding-top: calc(var(--mobile-header) + env(safe-area-inset-top)); + } + + .fundus-workspace { + min-height: auto; + grid-template-columns: minmax(0, 1fr); + padding: 0.7rem; + } + + .stream-workspace, + .stream-workspace:has(.stream-navigation.collapsed), + .stream-detail-workspace, + .stream-detail-workspace:has(.stream-navigation.collapsed) { + min-height: auto; + grid-template-columns: minmax(0, 1fr); + padding: 0.7rem; + } + + .stream-workspace > .shows-page { + padding-inline: 0.3rem; + } + + .fundus-navigation { + position: static; + display: block; + height: auto; + padding: 0.45rem; + overflow: hidden; + } + + .fundus-navigation header, + .fundus-navigation section { + display: none; + } + + .fundus-navigation > nav { + display: flex; + overflow-x: auto; + scrollbar-width: thin; + } + + .fundus-navigation > nav a { + min-width: max-content; + grid-template-columns: auto auto auto; + } + + .fundus-editor-workspace .fundus-navigation { + display: none; + } + + .fundus-editor-workspace .piece-editor-route { + grid-column: 1; + } + + .fundus-editor-surface { + height: auto; + min-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 2rem); + } + + .fundus-editor-surface .piece-dialog-workspace { + display: block; + overflow: visible; + } + + .fundus-editor-surface .piece-dialog-form, + .fundus-editor-surface .piece-script-panel { + display: block; + } + + .fundus-editor-surface .piece-editor-panel, + .fundus-editor-surface .piece-meta-details, + .fundus-editor-surface .piece-media-panel { + margin-top: 1rem; + } + + .fundus-editor-surface .piece-dialog-footer { + position: sticky; + z-index: 6; + bottom: calc(var(--mobile-nav) + env(safe-area-inset-bottom)); + align-items: flex-end; + flex-direction: column-reverse; + } +} + /* Shared editor controls introduced for the compact piece workflow. */ html:has(.heinzlive-output.empty), body:has(.heinzlive-output.empty), diff --git a/src/lib/components/FundusNavigation.svelte b/src/lib/components/FundusNavigation.svelte new file mode 100644 index 0000000..9e9e115 --- /dev/null +++ b/src/lib/components/FundusNavigation.svelte @@ -0,0 +1,163 @@ + + + diff --git a/src/lib/components/MarkdownEditorField.svelte b/src/lib/components/MarkdownEditorField.svelte deleted file mode 100644 index a9fd410..0000000 --- a/src/lib/components/MarkdownEditorField.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -
-
- {label}{#if privateField}Nur intern{/if} - - - - - - -
- -
diff --git a/src/lib/components/PieceEditorDialog.svelte b/src/lib/components/PieceEditorDialog.svelte index 6a75338..2df68a1 100644 --- a/src/lib/components/PieceEditorDialog.svelte +++ b/src/lib/components/PieceEditorDialog.svelte @@ -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(); 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(); } - { - event.preventDefault(); - onrequestclose(); - }} - onclick={(event) => event.target === dialog && onrequestclose()} -> +{#snippet editorChrome()}
{@render header()}
- + {#if !inline} + + {/if}
{@render children()}
{@render footer()}
-
+{/snippet} + +{#if inline} +
+ {@render editorChrome()} +
+{:else} + { + event.preventDefault(); + onrequestclose(); + }} + onclick={(event) => event.target === dialog && onrequestclose()} + > + {@render editorChrome()} + +{/if} diff --git a/src/lib/components/RichMarkdownField.svelte b/src/lib/components/RichMarkdownField.svelte new file mode 100644 index 0000000..50d6447 --- /dev/null +++ b/src/lib/components/RichMarkdownField.svelte @@ -0,0 +1,143 @@ + + +
+ +
+ {label}{#if privateField}Nur intern{/if} + + {#if !sourceMode} + + + + + + {/if} + + +
+ {#if sourceWarning}

+ Dieser Text enthält Markdown, das visuell nicht verlustfrei abgebildet werden kann. Die + Quellansicht bleibt deshalb aktiv. +

{/if} + + +
diff --git a/src/lib/components/ShareManagement.svelte b/src/lib/components/ShareManagement.svelte index 3afaacc..3c2208a 100644 --- a/src/lib/components/ShareManagement.svelte +++ b/src/lib/components/ShareManagement.svelte @@ -123,7 +123,7 @@

- Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen. + Streams gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.

{:else} @@ -153,7 +153,7 @@ {#if form?.intent?.toLowerCase().includes('share') && form.error}
{:else}
diff --git a/src/lib/components/StreamNavigation.svelte b/src/lib/components/StreamNavigation.svelte new file mode 100644 index 0000000..229627a --- /dev/null +++ b/src/lib/components/StreamNavigation.svelte @@ -0,0 +1,128 @@ + + + diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte index 541afa6..f964315 100644 --- a/src/lib/components/UsageEditorDialog.svelte +++ b/src/lib/components/UsageEditorDialog.svelte @@ -1,7 +1,7 @@ + + diff --git a/src/lib/server/fundus-workspace.ts b/src/lib/server/fundus-workspace.ts new file mode 100644 index 0000000..b20404a --- /dev/null +++ b/src/lib/server/fundus-workspace.ts @@ -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 + ]) + ) + } + }; +} diff --git a/src/lib/server/global-search.spec.ts b/src/lib/server/global-search.spec.ts new file mode 100644 index 0000000..c18a742 --- /dev/null +++ b/src/lib/server/global-search.spec.ts @@ -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\//); + }); +}); diff --git a/src/lib/server/global-search.ts b/src/lib/server/global-search.ts new file mode 100644 index 0000000..5e680c6 --- /dev/null +++ b/src/lib/server/global-search.ts @@ -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 | 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[] + ) + .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[] + ) + .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 }; +} diff --git a/src/lib/server/mcp.ts b/src/lib/server/mcp.ts index f7a7df1..2c3bb0c 100644 --- a/src/lib/server/mcp.ts +++ b/src/lib/server/mcp.ts @@ -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.' } }; } diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index b0d21b2..16beadb 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -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 diff --git a/src/lib/server/shows.spec.ts b/src/lib/server/shows.spec.ts index b23ef80..93ec8f9 100644 --- a/src/lib/server/shows.spec.ts +++ b/src/lib/server/shows.spec.ts @@ -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: [] diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts index e89d087..96bd585 100644 --- a/src/lib/server/shows.ts +++ b/src/lib/server/shows.ts @@ -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(() => { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 05d58e4..a519723 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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') ); } @@ -28,7 +27,7 @@ @@ -49,8 +48,8 @@
{@render children()}
{:else if data.user}
- +
- + Sendewerk -
- + +
- +
{@render children()}
+ + + {#if visiblePieces.length} +
+ {#each visiblePieces as piece (piece.id)} +
+ +
+ + + +
+ {#if piece.mediaId}{/if} +
+

+ {piece.title} +

+ {#if piece.summary}

{piece.summary}

{/if} + +
+
+ {piece.categories.map((item) => item.name).join(', ') || 'Offen'} +
+ {#if piece.formatId}{piece.format}{/if}{#each piece.topics as topic (topic.id)}{topic.name}{/each} +
+
+
+ {statusLabel[piece.status]}{#if piece.durationSeconds || piece.sourceCount}{#if piece.durationSeconds}{formatClock(piece.durationSeconds)} geplant{/if}{#if piece.sourceCount}{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}{/if}{/if} + > +
+
+ +
+
+ +
+
+ +
+
+ !confirm( + `Piece „${piece.title}“ löschen? Bestehende Streamfassungen bleiben erhalten.` + ) && event.preventDefault()} + > + + +
+
+
+
+ {/each} +
+ {:else} +
+ +

Kein passendes Piece

+

Andere Filter wählen oder ein neues Piece anlegen.

+ Filter zurücksetzen +
+ {/if} + diff --git a/src/routes/fundus/[id]/+page.server.ts b/src/routes/fundus/[id]/+page.server.ts index 1be3b5b..8c80ca6 100644 --- a/src/routes/fundus/[id]/+page.server.ts +++ b/src/routes/fundus/[id]/+page.server.ts @@ -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); diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index b063a01..d6fc0b1 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -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,596 +252,612 @@ }; -{draft.title} | Fundus | Sendewerk +{draft.title} | Pieces | Sendewerk -
- {#if form?.error}{/if} - - {#snippet header()} -
-

Fundusfassung

-
- - -
-
- -

{draft.title}

- {/snippet} - -
+ +
+ {#if form?.error}{/if} + - - - - - - - - - - - - {#snippet footer()} -
-
- Als Vorlage speichern -
- - - - +

Piece-Fassung

+ + +
-
-
- -
-
- -
-
- !confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') && - event.preventDefault()} - > - -
-
-
- {saveMessage} - -
- {/snippet} -
+ bind:value={draft.title} + oncommit={scheduleSave} + /> +

{draft.title}

+ {/snippet} -
- -
+
+ + +
+
+
+

Für die Moderation

+

Streamtext

+
+
+
+ + + + +
+
+ + Status, Dauer und Einordnung + {statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min. + +
+ + + + + +
+ Themen { + draft.topicIds = ids; + scheduleSave(); + }} + /> +
+
+ Vorgeschlagene Abschnittskategorien +
+ {#each data.taxonomy.categories as item (item.id)}{/each} +
+
+
+
+
+ +
+
+
+

Nicht öffentlich

+

Recherche & Quellen

+
+ Recherche und Material bleiben innerhalb der Redaktion. +
+ +
+
+
+

Fundstellen

+

Quellen

+
+ +
+ {#if sources.length}
+ {#each sources as source, index (index)}
+ +
{/each} +
{:else}

Noch keine Quelle hinterlegt.

{/if} +
+
+
+
+

Arbeitsmittel

+

Werkzeuge

+
+ tool.isActive) + .map((tool) => ({ + id: tool.id, + name: tool.name, + description: tool.description, + iconName: tool.iconName, + meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : '' + }))} + selectedIds={pieceTools.map((tool) => tool.toolId)} + label="Werkzeuge" + onselectionchange={selectTools} + /> +
+ {#if pieceTools.length}
+ {#each pieceTools as tool, index (tool.id)}
+ +
+ {tool.name}{#if !tool.isActive}Deaktiviert{/if} +
+ + + +
{/each} +
{:else}

Noch kein Werkzeug zugeordnet.

{/if} +
+
+ +
+
+
+

Öffentliche Ausgabe

+

Präsentation

+
+ In neuem Fenster testen +
+
+
+

Präsentationsart

+
+ {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}{/each} +
+ {#if draft.presentationMode === 'text'} + + + {:else} + + + {/if} + {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'} +
+ + + {#if draft.presentationMode === 'link'} + + {:else} + + {/if} + +
+ {draft.presentationMetadataFetchedAtUtc + ? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}` + : 'Noch keine Metadaten geladen.'} +
+
+ {:else} + + + + + {/if} +
+ +
+ + + + + + + + +
+
+ + + + {#snippet footer()} +
+
+ Als Vorlage speichern +
+ + + + +
+
+
+ +
+
+ +
+
+ !confirm('Piece löschen? Bestehende Streamfassungen bleiben erhalten.') && + event.preventDefault()} + > + +
+
+
+ {saveMessage} + +
+ {/snippet} + + +
+ +
+
diff --git a/src/routes/live/+page.server.ts b/src/routes/live/+page.server.ts index f7c3657..d18979c 100644 --- a/src/routes/live/+page.server.ts +++ b/src/routes/live/+page.server.ts @@ -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( diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index f2c5239..8a4d989 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -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'} > @@ -613,10 +613,10 @@ inert={mobileRailOpen} >
- Sendung steuern + Stream steuern
- Sendung + Stream {data.live.show.title} {showStateLabel()} @@ -627,7 +627,7 @@ Stream starten
@@ -646,7 +646,7 @@
{:else if data.live.endedAtUtc} {#if data.live.show.status === 'completed'} -

Diese Sendung ist historisch abgeschlossen.

+

Dieser Stream ist historisch abgeschlossen.

{:else}
@@ -681,7 +681,7 @@ type="button" onclick={() => completionDialog?.showModal()} > - Sendung abschließen + Stream abschließen {/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 @@ Stream beenden {/if} @@ -753,9 +753,9 @@ Ausgabe einrichten {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'} @@ -767,7 +767,7 @@ {#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 @@ Zur Planung - Die Sendung und öffentliche Ausgabe laufen unverändert weiter. + Der Stream und die öffentliche Ausgabe laufen unverändert weiter.
{/if} @@ -825,7 +825,7 @@

Unveränderliche Historie

-

Sendung abschließen

+

Stream abschließen

Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als @@ -838,7 +838,7 @@ >{data.completion.openCount} offen

- Originale bewusst in den Fundus-Archivbereich legen + Originale bewusst in den Piece-Archivbereich legen {#each data.completion.candidates as candidate (candidate.pieceId)}
{/each} + + {#if usage.intro}
+ Einleitung +

{usage.intro}

+
{/if} + {#if usage.moderation}
+ Moderation +

{usage.moderation}

+
{/if} + {#if usage.interaction}
+ Interaktion +

{usage.interaction}

+
{/if} + {#if usage.transition}
+ Überleitung +

{usage.transition}

+
{/if} + {#if usage.research}
+ Recherche +

{usage.research}

+
{/if} + {#if usage.ownNotes}
+ Eigene Notizen +

{usage.ownNotes}

+
{/if} + {/each} + {/each} + {:else} -
-
-
- Sendungen -

{data.show.title}

-

- {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} -

-
-
-
{plannedCount}Pieces
-
- {formatDuration(data.show.totalDurationSeconds)}Planzeit -
- Live-Steuerung -
-
- - {#if form?.error && !form.intent?.toLowerCase().includes('share')}{/if} - {#if form?.success && !form.intent?.toLowerCase().includes('share')}
- {form.success} -
{/if} -
{copyMessage}
- -
- Sendungsdaten und Twitch-Texte -
- - - - - - -
- {copyMessage} + +
+
+
+ Streams +

{data.show.title}

+

+ {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} +

- -
- {#if data.canManageShares}{/if} - -
- - -
- -
-
{ - if (dragged?.kind !== 'usage') return; - event.preventDefault(); - dropTarget = 'source'; - }} - ondrop={(event) => { - event.preventDefault(); - removeDraggedUsage(); - }} - > -
+
+
{plannedCount}Pieces
-

Quelle

-

Planbarer Fundus

+ {formatDuration(data.show.totalDurationSeconds)}Planzeit
- {visiblePieces.length} -
- {#if dropTarget === 'source'}
- Aus Sendung entfernenDas Fundus-Original bleibt erhalten. -
{/if} -
- Neues Piece direkt einplanen -
- - - - - - -
-
-
- Live-Steuerung - - - -
- -
-
- {#each visiblePieces as piece (piece.id)} -
-
startDrag('piece', piece.id, event)} - ondragend={finishDrag} - > -
- {piece.categories.map((item) => item.name).join(', ') || 'Offen'}{formatClock(piece.durationSeconds)} -
-

- {#if piece.isFavorite}{/if}{piece.title} -

-

- {piece.format} · {piece.topics.map((item) => item.name).join(', ') || - 'Ohne Thema'} -

- {#if piece.isPlannedInCurrentShow}Eingeplant{/if} - - Ziehen oder Ziel auswählen -
-
- - - -
-
- {/each} -
- {#if !visiblePieces.length}
- -

Keine verfügbaren Pieces

-

- Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig - markieren. -

-
{/if} -
+ -
-
-
-

Sendungsplan

-

Ablauf

-
- {formatDuration(data.show.totalDurationSeconds)} -
-
- {#each data.show.sections as section, index (section.id)} - - {/each} - {#if dropPreview?.kind === 'section' && dropPreview.position === data.show.sections.length} -
event.preventDefault()} - ondrop={dropAtPreview} - > - Hier einfügen{draggedTitle()} -
- {/if} -
+ {#if form?.error && !form.intent?.toLowerCase().includes('share')}{/if} + {#if form?.success && !form.intent?.toLowerCase().includes('share')}
+ {form.success} +
{/if} +
{copyMessage}
+ +
+ Streamdaten und Twitch-Texte
-
+ {#if data.canManageShares}{/if} + +
+ + +
+ +
+
{ + if (dragged?.kind !== 'usage') return; + event.preventDefault(); + dropTarget = 'source'; + }} + ondrop={(event) => { + event.preventDefault(); + removeDraggedUsage(); + }} + > +
+
+

Quelle

+

Planbare Pieces

+
+ {visiblePieces.length} +
+ {#if dropTarget === 'source'}
+ Aus Stream entfernenDas Piece-Original bleibt erhalten. +
{/if} +
+ Neues Piece direkt einplanen +
+ + + + + + +
+
+
+ + + +
+
+ + +
+
+ {#each visiblePieces as piece (piece.id)} +
startDrag('usage', usage.id, event)} + ondragstart={(event) => startDrag('piece', piece.id, event)} ondragend={finishDrag} > - {usage.title}{formatClock(usage.durationSeconds)} +
+ {piece.categories.map((item) => item.name).join(', ') || + 'Offen'}{formatClock(piece.durationSeconds)} +
+

+ {#if piece.isFavorite}{/if}{piece.title} +

+

+ {piece.format} · {piece.topics.map((item) => item.name).join(', ') || + 'Ohne Thema'} +

+ {#if piece.isPlannedInCurrentShow}Eingeplant{/if} + + Ziehen oder Ziel auswählen
-
+ -
-
- !confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()} - > - +
- -
-
- {/each} - {#if showsUsagePreview(null, data.show.reserve.length)} -
- {draggedTitle()}Hier in die Reserve einfügen -
- {/if} - {#if !data.show.reserve.length}

- Pieces lassen sich hier parken, ohne die geplante Dauer zu erhöhen. -

{/if} + + {/each} +
+ {#if !visiblePieces.length}
+ +

Keine verfügbaren Pieces

+

+ Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Bereich Pieces + sendefertig markieren. +

+
{/if} - -
-
- -
-
- -
-
- -
-
- - -
+
+
+
+

Streamplan

+

Ablauf

+
+ {formatDuration(data.show.totalDurationSeconds)} +
+
+ {#each data.show.sections as section, index (section.id)} + + {/each} + {#if dropPreview?.kind === 'section' && dropPreview.position === data.show.sections.length} +
event.preventDefault()} + ondrop={dropAtPreview} + > + Hier einfügen{draggedTitle()} +
+ {/if} +
+
+ +
+ +
previewUsage(event, null, data.show.reserve.length, false)} + ondrop={dropAtPreview} + > +
+
+ +
+ Reservebank{data.show.reserve.length} + {data.show.reserve.length === 1 ? 'Piece' : 'Pieces'} · zählt nicht zur Planzeit +
+
+
+ {#each data.show.reserve as usage, usageIndex (usage.id)} + + {/each} + {#if showsUsagePreview(null, data.show.reserve.length)} +
+ {draggedTitle()}Hier in die Reserve einfügen +
+ {/if} + {#if !data.show.reserve.length}

+ Pieces lassen sich hier parken, ohne die geplante Dauer zu erhöhen. +

{/if} +
+
+ + +
+ +
+
+ +
+
+ +
+
+ + +
+ {/if} diff --git a/src/routes/suche/+page.server.ts b/src/routes/suche/+page.server.ts new file mode 100644 index 0000000..67c8159 --- /dev/null +++ b/src/routes/suche/+page.server.ts @@ -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) }; +}; diff --git a/src/routes/suche/+page.svelte b/src/routes/suche/+page.svelte new file mode 100644 index 0000000..e6ba25f --- /dev/null +++ b/src/routes/suche/+page.svelte @@ -0,0 +1,95 @@ + + +Suche | Sendewerk + +
+
+

Arbeitsbereich

+

Suche

+

Durchsuche Pieces und Streams einschließlich abgeschlossener und archivierter Inhalte.

+
+ + + + {#if data.query.length === 1} +

Bitte mindestens zwei Zeichen eingeben.

+ {:else if data.query} +
+
+
+

Pieces

+ {data.results.pieces.length} +
+ {#if data.results.pieces.length} + + {:else}

Keine passenden Pieces.

{/if} +
+ +
+
+

Streams

+ {data.results.shows.length} +
+ {#if data.results.shows.length} + + {:else}

Keine passenden Streams.

{/if} +
+
+ {/if} +
diff --git a/src/routes/verwaltung/+page.svelte b/src/routes/verwaltung/+page.svelte index 1632ecb..e0961b8 100644 --- a/src/routes/verwaltung/+page.svelte +++ b/src/routes/verwaltung/+page.svelte @@ -275,7 +275,7 @@

- Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten. + Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Streams erhalten.

{#each data.tools as tool (tool.id)} diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index 640f03a..6284c0d 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -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 = { + '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 **sichtbar**'); + await fillMarkdownSource( + page, + 'Öffentlicher Vorspann', + '## Sichere Vorschau\n\n **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') );