From a6c8606f3c6f1cd3612b7f5d84f2459f6b9cae03 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Fri, 30 Oct 2026 17:33:13 +0100 Subject: [PATCH] release: add post-production workspace 0.9.0 --- CloudronManifest.json | 2 +- docs/ACCEPTANCE.md | 104 + docs/ARCHITECTURE.md | 180 +- docs/DECISIONS.md | 131 + docs/RELEASE_NOTES.md | 136 + docs/STATUS.md | 173 + migrations/0021_post_production.sql | 127 + migrations/0022_post_quick_workbench.sql | 15 + package-lock.json | 4 +- package.json | 2 +- scripts/seed.mjs | 29 +- src/app.css | 4237 ++++++++++++++++- src/lib/components/AppIcon.svelte | 2 +- src/lib/components/FundusNavigation.svelte | 193 +- src/lib/components/NavIcon.svelte | 2 +- src/lib/components/PieceEditorDialog.svelte | 45 +- src/lib/components/PostNavigation.svelte | 136 + src/lib/components/RichMarkdownField.svelte | 20 +- src/lib/components/StreamInspector.svelte | 258 + src/lib/components/UsageEditorDialog.svelte | 491 -- src/lib/server/database.spec.ts | 6 +- src/lib/server/live.ts | 2 + src/lib/server/post-ai.ts | 45 + src/lib/server/post.spec.ts | 189 + src/lib/server/post.ts | 1152 +++++ src/lib/server/shows.spec.ts | 55 + src/lib/server/shows.ts | 47 + src/routes/+layout.server.ts | 3 +- src/routes/+layout.svelte | 28 +- src/routes/eingang/+page.server.ts | 42 +- src/routes/eingang/+page.svelte | 325 +- src/routes/fundus/+page.server.ts | 68 +- src/routes/fundus/+page.svelte | 480 +- src/routes/fundus/[id]/+page.svelte | 356 +- src/routes/live/+page.server.ts | 348 +- src/routes/live/+page.svelte | 1565 ++---- src/routes/post/+page.server.ts | 35 + src/routes/post/+page.svelte | 153 + src/routes/post/[id]/+page.server.ts | 269 ++ src/routes/post/[id]/+page.svelte | 1144 +++++ .../[id]/export/[itemId]/[kind]/+server.ts | 30 + .../post/[id]/source/[sourceId]/+server.ts | 20 + .../[id]/thumbnail/[thumbnailId]/+server.ts | 22 + src/routes/sendungen/[id]/+page.server.ts | 16 + src/routes/sendungen/[id]/+page.svelte | 246 +- tests/ui.e2e.ts | 1265 +++-- 46 files changed, 10783 insertions(+), 3415 deletions(-) create mode 100644 migrations/0021_post_production.sql create mode 100644 migrations/0022_post_quick_workbench.sql create mode 100644 src/lib/components/PostNavigation.svelte create mode 100644 src/lib/components/StreamInspector.svelte delete mode 100644 src/lib/components/UsageEditorDialog.svelte create mode 100644 src/lib/server/post-ai.ts create mode 100644 src/lib/server/post.spec.ts create mode 100644 src/lib/server/post.ts create mode 100644 src/routes/post/+page.server.ts create mode 100644 src/routes/post/+page.svelte create mode 100644 src/routes/post/[id]/+page.server.ts create mode 100644 src/routes/post/[id]/+page.svelte create mode 100644 src/routes/post/[id]/export/[itemId]/[kind]/+server.ts create mode 100644 src/routes/post/[id]/source/[sourceId]/+server.ts create mode 100644 src/routes/post/[id]/thumbnail/[thumbnailId]/+server.ts diff --git a/CloudronManifest.json b/CloudronManifest.json index 1a20f48..f3b2517 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.8.1", + "version": "0.9.0", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 76d1be6..2a3919d 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,5 +1,109 @@ # Abnahme +## Einheitliche Inhaltswerkbänke 0.9.0 + +| Bereich | Nachweis | Stand | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| Gemeinsame Übersichtshierarchie | Pieces, Eingang und Post zeigen Navigation/Quellen, mittlere Auswahl und rechte Schnellbearbeitung aus denselben Layoutgrundlagen | erfüllt am 29.10.2026 | +| Piece-Schnellbearbeitung | Titel, Kurzbeschreibung, Einleitung, Moderation, Status, Dauer, Format und Bild-Upload speichern revisionsgeschützt; Voll-Editor öffnet separat | erfüllt am 29.10.2026 | +| Getrennte RSS-Sichtung | RSS-Vorschau, Originallink, Übernehmen und Verwerfen bleiben vor der bewussten Piece-Erzeugung getrennt | erfüllt am 29.10.2026 | +| Post ohne zweite Sendungsverwaltung | Übersicht beginnt bei abgeschlossenen Streams; Vollbearbeitung verwendet vorhandene Post- und Streamdienste | erfüllt am 29.10.2026 | +| Eigenständige Vollbearbeitung | Piece bleibt modal; Post besitzt eine normale eigene Seite im Streams-Layout mit Streamkopf, Zurücklink und Schnellnavigation | erfüllt am 30.10.2026 | +| Einheitliche Auswahlflächen | Aktive Karten, Checkboxen und Radios folgen Streams: abgerundete ruhige Fläche mit dünner goldener Kontur, ohne Cyan-Füllung oder Seitenbalken | erfüllt am 30.10.2026 | +| Responsive Gestaltung | Chromium prüft Übersichten bei 1440 × 1000, 960 × 900 und 390 × 844 sowie Piece-Overlay und Post-Detailseite ohne horizontalen Überlauf | erfüllt am 30.10.2026 | +| Bestehende Fachgrenzen | Autosave, Revisionen, Medien, RSS-Import, Stream-/Live-Snapshots, Post-Exporte, Rechte und private Dateien bleiben erhalten | erfüllt am 29.10.2026 | +| Live als Nur-Lesen-Projektion | Normale Navigation, Streamkopf und drei Pane-Flächen; Auswahl und Darstellung ändern nachweislich weder Show-/Usage-Status noch Reihenfolge | erfüllt am 30.10.2026 | +| Verbindliches Seitenraster | Linke Spalte beginnt oben; freier Seitenkopf und darunter liegende Inhalts-/Inspectorflächen nutzen in allen Werkbänken dieselben Maße | erfüllt am 30.10.2026 | +| Reduzierte App-Bewegung | Piece-Overlay schließt nach echtem Animationsende; Dialog-/Pane-Wechsel bleiben unter 200 ms und respektieren reduzierte Bewegung | erfüllt am 30.10.2026 | + +- Gezielte Chromium-Abläufe für Piece-Auswahl, RSS, Schnellaufnahme, + Piece-Autosave/Präsentationsmodus, Streamplanung und Postproduktion: erfolgreich. +- Desktop-, Zwischenbreiten- und Mobilaufnahmen der drei Übersichten sowie der + Piece- und Post-Vollbearbeitung wurden erzeugt und visuell kontrolliert. +- Korrekturstand 30.10.2026: Post-Navigation, Piece-/Post-Schnellinspektoren und + Post-Vollbearbeitung verwenden nachweislich die Stream-Basisklassen. Gezielte + Chromium-Abläufe für Fundus und Post liefen erfolgreich; die Aufnahmen bei + 1440, 960 und 390 Pixeln wurden erneut visuell kontrolliert. +- Die vollständige Chromium-Matrix bedient die vorhandenen Checkboxen und Radios + weiterhin über ihre native Semantik. Post-Detailübersicht ohne Auswahl und + mobile Schnellnavigation wurden separat aufgenommen und visuell kontrolliert. +- `npm run lint`: erfolgreich. +- `npm run check`: 0 Fehler und 0 Warnungen. +- `npm test`: 69 von 69 Verhaltenstests erfolgreich. +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`. +- `npm run test:e2e`: 22 von 22 Chromium-Abläufen erfolgreich. +- Live-Nur-Lesen-Test: Desktop-, Kompakt- und Mobilansicht erfolgreich; lokale + Auswahl, Vor/Zurück, Präsentieren, Darstellungswechsel und ein abgewiesener + Status-POST ließen Show-/Usage-Status und Reihenfolge unverändert. +- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen. + +## Postproduktion 0.9.0 + +| Bereich | Nachweis | Stand | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| Bestehende abgeschlossene Streams | `/post` zeigt ausschließlich vorhandene Streams mit Status `completed`; Suche, Sortierung und Fortschrittsfilter erzeugen keine zweite Sendung | erfüllt am 29.10.2026 | +| Responsive Arbeitsfläche | Chromium prüft die normale Detailseite mit drei Desktopspalten und mobiler Umschaltung zwischen Inhalte, Bearbeiten und Details ohne Überlauf | erfüllt am 30.10.2026 | +| Stream-artige Gestaltung | Detailkopf, Datumszeile, Formfelder, Radien, Abstände und aktive Auswahl entsprechen unmittelbar der Streams-Referenz | erfüllt am 30.10.2026 | +| Direkte Streamwerkbank | Klick auf Streamzeile oder Öffnen-Aktion führt direkt zur Werkbank; ohne Einzelwahl erscheinen Inhaltsübersicht und Streammetadaten | erfüllt am 30.10.2026 | +| Schnelle Inhaltsnavigation | Links stehen YouTube, Social-Clips und Twitch-Highlights mit Sofortanlage; aktive Inhalte wechseln farblich markiert in den Editor | erfüllt am 30.10.2026 | +| Aufzeichnungen und Kontext | Mehrere Teile und Markdown-Dateien sind persistent; SHA-256-Dublettenschutz, unveränderter Inhalt und konservative Zeitmarkenextraktion sind im Servicetest geprüft | erfüllt am 29.10.2026 | +| YouTube-Uncuts | Beschreibung, Titelvarianten, Thumbnailkonzept, private echte Bilddatei, Auswahl, Download, Status und Veröffentlichungsdaten sind verfügbar | erfüllt am 29.10.2026 | +| Social-Clips | Beliebig viele Clips führen Shorts-Titel, gemeinsame TikTok-/Instagram-Caption samt Hashtags, Hooks, Endcards, Ziele, Untertitel und Schnittdaten | erfüllt am 30.10.2026 | +| Kontextbezogene Zuordnung | Eine einzige Kontextdatei wird automatisch gesetzt; Piece-Optionen stammen ausschließlich aus tatsächlichen Verwendungen des geöffneten Streams | erfüllt am 30.10.2026 | +| Twitch-Highlights | Eigenständiger Typ mit Beschreibung, Originaltranskript, Zeitbereich, Schnitt- und Veröffentlichungsdaten | erfüllt am 29.10.2026 | +| Status ohne Aufgaben/Analytics | Revisionierter Workflow Entwurf bis Veröffentlicht; keine Aufgaben-, Kennzahlen- oder Publikationsautomatik | erfüllt am 29.10.2026 | +| Private Dateien | Kontextdateien und Thumbnails liegen im konfigurierten Uploadpfad und werden ausschließlich über authentifizierte, private Routen ausgeliefert | erfüllt am 29.10.2026 | +| Valide Exporte | Servicetests blockieren unvollständige Resolve-/SRT-Daten; Chromium lädt XML und SRT erst mit allen technisch erforderlichen Werten | erfüllt am 29.10.2026 | +| Manueller KI-unabhängiger Ablauf | Kein Netzwerkaufruf und keine Scheinaktion; providerneutraler Adaptervertrag bleibt unkonfiguriert und der gesamte Ablauf manuell bedienbar | erfüllt am 29.10.2026 | +| Bestehende Abläufe | Vollständige Verhaltenstest- und Chromium-Matrix für Pieces, Streams, Live, Abschluss, Freigaben, RSS, Rechte und Präsentation bleibt erfolgreich | erfüllt am 29.10.2026 | + +- `npm run lint`: erfolgreich. +- `npm run check`: 0 Fehler und 0 Warnungen. +- `npm test`: 69 von 69 Verhaltenstests erfolgreich. +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`. +- `npm run test:e2e`: 22 von 22 Chromium-Abläufen erfolgreich. +- Gezielter Post-Lauf nach der CSS-Korrektur: 1 von 1 erfolgreich; Registerwechsel + explizit per Mausklick geprüft. Desktop- und Mobilaufnahmen einschließlich + inhaltsloser Übersicht und mobiler Schnellnavigation visuell kontrolliert. +- `git diff --check`: erfolgreich. +- Safari, echte Mobilgeräte, Import realer heinzlive-Kontextdateien, Öffnen in + DaVinci Resolve und eine reale KI-Schreibschnittstelle: mangels Umgebung offen. + +## Piece-Schreibraum 0.8.2 + +| Bereich | Nachweis | Stand | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| Zusammenhängendes Dokument | Vier getrennt gespeicherte Bereiche ohne Einzelkarten; nur Fokus zeigt Werkzeuge, Auswahl bleibt beim Formatieren erhalten | erfüllt am 28.10.2026 | +| Recherche und Markdown | Bereichswechsel erhält Eingaben; Recherche, Quellen, Zeitmarken, Werkzeuge und Markdown-Quellansicht bleiben vollständig | erfüllt am 28.10.2026 | +| Kontextbezogene Piece-Liste | Editor zeigt gefilterte Liste, aktuelle Markierung, Suche/Filter und Rücksprung; schmutziger Wechsel speichert vor Navigation | erfüllt am 28.10.2026 | +| Kompakte Details und Ausgabe | Häufige Werte sind direkt editierbar, seltene eingeklappt; Moduswahl und editierbare Publikumsvorschau liegen sichtbar darunter | erfüllt am 28.10.2026 | +| Kopf und Aktionen | Speicherstatus/-aktion und Favorit sind kompakt; Vorlagen, Duplikation und bestätigte destruktive Aktionen liegen im Menü | erfüllt am 28.10.2026 | +| Kopf unter schmaler Breite | Titel, Favorit, Speicher-/Aktionssteuerung und Bereichsauswahl belegen getrennte Zeilen ohne Abschneiden | erfüllt am 28.10.2026 | +| Responsive Gestaltung | Chromium prüft 1440 × 1000 offen/eingeklappt, 960 × 900 sowie mobilen Streamtext, Piece-Drawer und Details ohne Überlauf | erfüllt am 28.10.2026 | +| Eingeklappte Zwischenbreite | Bei 960 × 900 wächst die Editorroute mit eingeklappter Navigation über 820 Pixel; Icons sind mindestens 16 Pixel groß, Version sichtbar | erfüllt am 28.10.2026 | +| Piece-Liste und Streamplan | Scrollgutter hält die Piece-Markierung bündig; der Streamplan zeigt rechts den Inspector in derselben kompakten Breitenklasse | erfüllt am 28.10.2026 | +| Kontextbezogener Inspector | Ohne Auswahl erscheinen Stream-/Twitch-Daten; Abschnitt und Piece wechseln per Klick zu ihren Metadaten und festen Speicheraktionen | erfüllt am 28.10.2026 | +| Sichere Usage-Schnelländerung | Titel, Publikumstitel, Dauer, Vorbereitung und Notizen speichern revisionsgeschützt, ohne Langtexte oder Präsentationswerte zu leeren | erfüllt am 28.10.2026 | +| Responsive Streamdetails | Desktop hält Ablauf und Inspector nebeneinander; schmalere Ansichten bieten Details als dritten tastatur- und touchfähigen Arbeitsbereich | erfüllt am 28.10.2026 | +| Live-Regie | Chromium prüft Dokument, flachen Ablauf ohne Aktionsüberlagerung, feste Steuerung, 900/670 × 450 und mobiles Ablauf-Sheet | erfüllt am 28.10.2026 | +| Bestehende Fachgrenzen | Autosave, Revisionen, Medien, Stream-Snapshots, globale Navigation und benachbarte Arbeitsbereiche bleiben unverändert | erfüllt am 28.10.2026 | + +- Gezielte Chromium-Abläufe für Schreibraum, Auswahl erhaltende Formatierung, + Autosave-Piece-Wechsel, Konflikte, öffentliche Überschrift, Upload, + Link-/Videopräsentation und mobile Ansichtswechsel: erfolgreich. +- Sichtkontrolle der korrigierten Geometrie bei 1440 × 1000 mit ausgeklappter + und eingeklappter Navigation, bei 960 × 900, im Streamplan-Inspector sowie in + der Live-Regie bei 1440 × 900, 900/670 × 450 und 390 × 700: erfolgreich; + Desktop-, Streamtext-, Drawer-, Inspector- und Live-Screenshots wurden + erzeugt und kontrolliert. +- `npm run lint`: erfolgreich. +- `npm run check`: 0 Fehler und 0 Warnungen. +- `npm test`: 64 von 64 Verhaltenstests erfolgreich. +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`. +- `npm run test:e2e`: 22 von 22 Chromium-Abläufen erfolgreich. +- `git diff --check`: erfolgreich. +- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen. + ## Redaktionelle Werkbänke 0.8.1 | Bereich | Nachweis | Stand | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8b5c47c..3e71474 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,8 +21,8 @@ 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 +`+layout.svelte` stellt für angemeldete Nutzer die globalen Bereiche Start, +Eingang, Pieces, Streams, Live und Post 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. @@ -48,11 +48,14 @@ 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. +`StreamInspector.svelte` ist die dauerhafte rechte Projektion des Streamplans. +Seine flüchtige Auswahl referenziert Show, Abschnitt oder Usage nur über stabile +IDs. Der Standardzustand bearbeitet Show- und Twitch-Metadaten; Abschnitt und +Usage verwenden ihre bestehenden revisionsgeschützten Actions. Die zusätzliche +Action `updateUsageMetadata` liest die aktuelle Usage serverseitig und führt +nicht dargestellte Langtexte, Präsentationswerte, Medien und Werkzeuge in +`updatePieceUsage()` unverändert mit. Ein schmales Clientformular kann private +Felder daher weder versehentlich leeren noch die Geschäftsvalidierung umgehen. Seit 0.8.1 liest `/suche` die optionalen URL-Filter `type`, `status`, `topic` und `format` serverseitig. Themen und Formate stammen direkt aus den bestehenden @@ -60,11 +63,25 @@ Taxonomietabellen; Piece-Filter wirken nicht auf Streams. Die Trefferprojektion und ihre begrenzten Ausschnitte bleiben unverändert, es entsteht weder ein Suchindex noch persistenter Filterzustand. -Die Piece-, Planungs- und Live-Arbeitsflächen ändern ausschließlich ihre -Darstellung. Im Streamplan ist der Ablauf die Hauptfläche und die Piece-Auswahl -eine bei Bedarf geöffnete Nebenspalte. Dieselben Formulare, Revisionen, -Drag-Daten, Fokusgrenzen und mobilen Pane-/Sheet-Zustände bleiben die -Interaktionsgrenze. +Die Piece-, Eingangs-, Post-, Planungs- und Live-Arbeitsflächen verwenden eine +gemeinsame Oberflächensprache. Pieces, Eingang und Post teilen die Klasse `unified-workspace`: +links Navigation oder Quellen, mittig die auswählbare Inhaltsprojektion und +rechts `content-quick-inspector` für häufige Felder und Aktionen. Die Auswahl ist +flüchtiger URL- beziehungsweise Browserzustand und erzeugt kein Fachobjekt. Im Streamplan ist der Ablauf die Hauptfläche, der Inspector die +dauerhafte rechte Metadatenspalte und die Piece-Auswahl eine bei Bedarf geöffnete +Nebenspalte. Eine Auswahl schließt diese temporäre Bibliothek. Unterhalb der +Desktopbreite sind Pieces, Ablauf und Details gleichwertige Pane-Zustände. +Revisionen, Drag-Daten und Fokusgrenzen bleiben die Interaktionsgrenze. Live ist +eine reguläre Seite der App-Shell und projiziert Ablauf, Moderationsdokument und +Details ausschließlich lesend. Die lokale Piece-Auswahl und Darstellungsoptionen +ändern weder den tatsächlichen Live-Stand noch Usage-Status, Reihenfolge, Timer +oder öffentliche Ausgabe. + +Die gemeinsamen Klassen sind bewusst keine bloße Rasterabstraktion. Pieces und +Post verwenden für linke Kataloge dieselbe `fundus-navigation`-Basis wie Streams +und für rechte Projektionen `stream-inspector` zusammen mit `builder-pane`. +Dadurch bleiben Kopf, Formularrhythmus, Scrollgrenze und Aktionsfuß identisch. +Post ergänzt nur fachliche Felder und eine kompakte Inhaltswerkzeugzeile. ## Laufzeit und Speicher @@ -95,6 +112,7 @@ technische API. Folgende Grenzen gelten für umgesetzte und spätere Bereiche. | Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | | RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | | API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | +| Post | Aufzeichnungsteile, private Markdown-Quellen, revisionierte YouTube-/Clip-/Highlight-Elemente, Thumbnails und valide Exporte | | Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | ### Historien- und Kopierregel @@ -424,9 +442,52 @@ Sendungsroute bindet Mutationen zusätzlich an ihre eigene Sendungs-ID. Die Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen Status und Termin. +## Postproduktion + +`src/lib/server/post.ts` ist die gemeinsame Geschäftsgrenze für den Hauptbereich +`/post`. Jede Übersicht beginnt bei `shows.status = 'completed'`; Post besitzt +keine eigene Sendungsidentität und kann weder Entwürfe noch laufende Streams +nachträglich als abgeschlossen markieren. `post_items` hält gemeinsame Identität, +Typ, Workflowstatus, Herkunft, Position und Revision. Typbezogene 1:1-Tabellen +speichern YouTube-, Social-Clip- oder Twitch-Highlight-Felder. + +Migration `0022_post_quick_workbench.sql` ergänzt `social_caption` an +`post_social_clips` und führt vorhandene TikTok-/Instagram-Captions und Hashtags +verlustarm in das gemeinsame Feld über. YouTube Shorts verwendet im aktuellen +Formvertrag ausschließlich `shorts_title`; TikTok und Instagram teilen die +Caption inklusive Hashtags. Die älteren Spalten bleiben für die additive +Migration bestehen, werden von der neuen Oberfläche aber nicht mehr getrennt +bearbeitet. + +`post_recording_parts` beschreibt mehrere Teile einer Aufzeichnung. Medienpfade +sind redaktionelle Verweise und müssen für Resolve-Export absolut oder als +`file://` angegeben sein; Framerate und Timebase werden separat validiert. +`post_source_files` speichert Originalinhalt, privaten zufälligen Speichernamen, +SHA-256, Größe sowie ausschließlich aus tatsächlich gefundenen Zeilen abgeleitete +Transkript- und Zeitmarkenprojektionen. `(show_id, sha256)` verhindert Dubletten +innerhalb einer Sendung, ohne Dateien sendungsübergreifend zusammenzuführen. + +Kontextdateien und `post_thumbnails` liegen im konfigurierten Uploadverzeichnis. +Die Routen unter `/post/[id]/source` und `/post/[id]/thumbnail` verlangen eine +Redaktionssitzung, prüfen die Zuordnung zur abgeschlossenen Sendung und liefern +private Sicherheitsheader. Originalnamen sind Metadaten und niemals Pfade. Pro +YouTube-Element kann ein Thumbnail ausdrücklich ausgewählt werden. + +Exporte unter `/post/[id]/export` lesen dieselbe positiv validierte Projektion. +Resolve-XML benötigt Aufzeichnungspfad, Framerate, Timebase und einen positiven +Start-/Endbereich. SRT wird cueweise vollständig geprüft; unvollständige Daten +führen zu `EXPORT_NOT_READY` statt zu einer scheinbar gültigen Datei. Text- und +JSON-Exporte erzeugen keine externe Veröffentlichung. + +`src/lib/server/post-ai.ts` enthält nur Typen für einen später konfigurierbaren +Provideradapter. Es gibt weder Netzwerkaufruf noch automatisch erzeugten Inhalt. +Importierte Quellen und manuelle Bearbeitung bleiben die vollständige +Produktfunktion. + ## Oberflächenstruktur -Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. +Die Hauptnavigation umfasst Start, Eingang, Pieces, Streams, Live und Post; +Verwaltung bleibt im Benutzermenü. 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 @@ -437,31 +498,34 @@ Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwisch Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare, touchfreundliche Agenda desselben Monats. -Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die -Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht -ohne interne Anwendungsnavigation. +Die private Route `/live` läuft in derselben authentifizierten App-Shell wie +Streams. Ein Streamkopf zeigt Titel, Termin, Umfang und den expliziten +Nur-Lesen-Zustand. Darunter folgen auf breiten Ansichten Ablauf, Lesedokument und +Details als drei `builder-pane`-/`stream-inspector`-artige Flächen. Bis +einschließlich `72rem` werden diese Bereiche über eine gleichwertige Umschaltung +einzeln gezeigt; bis `52rem` bleiben der kompakte App-Header und die mobile +Bottomnavigation sichtbar. -Die private Route `/live` verwendet ebenfalls einen eigenen authentifizierten -Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder -Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster -aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie -fester Aktionszeile. Die Breite entscheidet über zwei Spalten oder das mobile -Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten, -ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints -wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung -eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand -aufgehoben. Die lokale Betrachtungsnavigation ist keine Live-Action; erst -Präsentieren, Überspringen und Erledigen schreiben den jeweiligen getrennten -Zustand. Die öffentliche `/heinzlive`-Projektion bleibt unverändert. Lokale -Lesepositionen sind an die jeweilige Verwendung gebunden und werden nicht -zwischen Pieces übertragen. +`src/routes/live/+page.server.ts` bietet neben `load` ausschließlich die getrennte +Präsentationsaktion an. Das Auswählen einer Usage, Vor/Zurück, Schriftgröße, +sichtbare Zeiten und das Kopieren von Twitch-Tags bleiben lokaler +Browserzustand. Status-, Fortschritts-, Sortier- und Abschlussänderungen sind +nicht Teil dieser Route. Präsentieren ändert nur `presentation_usage_id` und +damit weder Show-/Usage-Status noch Reihenfolge. Die öffentliche `/heinzlive`- +Projektion und die serverseitigen historischen Live-Dienste bleiben erhalten. -Der Live-Kopf besteht aus zwei regulären Rasterzeilen. Abschnitt und folgendes -Piece sind einzeilig gekürzte, fokussierbare Schaltflächen, die ihren vollständigen -Text lokal aufklappen. Das Regiemenü wird bei Bedarf als fest am Viewport -positionierter Dialog neben dem Kopf gerendert. Dadurch unterliegt es weder dem -Overflow der Regiefläche noch dem durch Glasfilter erzeugten Stacking Context; -seine maximale Höhe bleibt an den Viewport gebunden und intern scrollbar. +Das gemeinsame Arbeitsraster besteht aus einer linken, über Kopf und Inhalt +reichenden Navigation sowie einem rechten Hauptbereich. Dessen freie Kopfzeile +besitzt bewusst keine Pane-Fläche; darunter beginnen Inhalt und optionaler +Inspector. `unified-workspace`, Post-Detail und Live verwenden dieselben +Spaltenwerte `13–16rem / minmax(0, 1fr) / 19–22rem` und `0.85rem` Abstand. Bei +mittleren Breiten folgt der Inspector unter dem Inhalt, mobil übernimmt die +bereichsspezifische Pane-Umschaltung. + +Dialog- und Pane-Bewegungen sind reine Oberflächenzustände mit 140–170 ms Dauer. +Der Piece-Dialog animiert Öffnen, explizites Schließen und Backdrop; Disclosure- +Inhalte und mobile Pane-Wechsel blenden kurz ein. Unter +`prefers-reduced-motion: reduce` werden Animationen und Übergänge entfernt. Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter `static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren @@ -470,20 +534,50 @@ 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. +mittlerer Liste und rechter Schnellbearbeitung. Kleinere Desktopfenster ordnen +den Inspector unter der Liste an; mobil werden Navigation, Liste und Inspector +gestapelt. URL-Parameter tragen Suche, Filter und ausgewählte Piece-ID über den +Routenwechsel, `sessionStorage` ausschließlich die zugehörige flüchtige +Listenposition. Reduzierte Schnellformulare lesen nicht dargestellte Werte +serverseitig aus dem aktuellen Piece und führen sie unverändert mit. -Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles -Arbeitsblatt ohne Desktopdialog an. Titel, Einleitung, Moderationsstichpunkte, +Der eigenständige Piece-Editor ordnet denselben Formvertrag als modales +redaktionelles Arbeitsblatt mit eigener URL 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. +Der Eingang verwendet dieselbe Darstellung, behält aber zwei Datenarten: neue +`feed_entries` und bereits erzeugte Inbox-Pieces. Die rechte Projektion ruft für +RSS weiterhin ausschließlich Import-/Verwerfactions auf. Post beginnt ebenfalls +in einer dreiteiligen Übersicht; die vollständige Streamzeile und die rechte +Öffnen-Aktion verlinken direkt auf `/post/[id]`. Die Route ist eine normale +Anwendungsseite und übernimmt Seitenkopf, Datumsdarstellung, Feldgeometrie und +responsive Pane-Umschaltung der Streambearbeitung. Darunter stehen +Schnellnavigation, Editor/Übersicht und Inspector. Die linke Navigation +gruppiert YouTube, Social-Clips und Twitch-Highlights und ruft deren Sofortanlage +über dieselbe Post-Action auf. Ohne `item`-Queryparameter bleibt die Auswahl +leer: Die Mitte projiziert alle vorhandenen Post-Inhalte mit Kopieraktionen, +rechts stehen Streamzähler. Mit `item` zeigt dieselbe Mitte den Editor und rechts +dessen Eigenschaften. Mutations-URLs führen `item` mit, damit Upload oder +Speichern die aktuelle Auswahl nicht verlieren. + +`createPostItem()` setzt `source_file_id` bereits beim Anlegen, wenn genau eine +Kontextdatei zum Stream existiert. `listPostPieceOptions(showId)` verbindet +`pieces`, `piece_usages` und `show_sections`; dadurch sind nur Original-Pieces +des geöffneten Streams zuordenbar. Editor und Inspector nutzen dieselben Pane-, +Feld- und Aktionsgrundlagen wie der Streamplan. Mobil werden Navigation, +Bearbeitung und Details als drei explizite Pane-Zustände angeboten. + +Checkboxen und Radios bleiben im DOM native Formularsteuerungen. Das globale +Stylesheet blendet nur ihre sichtbaren Kästchen aus und projiziert `:checked` und +`:focus-visible` auf das umgebende Label. Damit sind ganze Flächen per Maus, +Touch und Tastatur wählbar, ohne Semantik oder Formübermittlung zu ersetzen. Der +aktive Zustand verwendet wie Post-Navigation und Post-Register den cyanfarbenen +Oberflächenakzent; Gold bleibt Primäraktionen vorbehalten. + `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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 13effdd..86e62a1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -634,3 +634,134 @@ aus der URL angewendet. Status gilt für beide Treffergruppen; Thema und Format gelten ausschließlich für Pieces und schließen Streamtreffer aus. Taxonomie und Trefferzahlen werden aus SQLite abgeleitet. Es gibt keinen neuen Index, keine Migration und keinen konto- oder browsergebundenen Filterzustand. + +## D-038: Stream-Inspector statt Usage-Overlay + +Die nicht-modale Dialoglösung aus D-036 wird für die Streamplanung durch eine +dauerhafte rechte Inspector-Spalte ersetzt. Der Ablauf bleibt dadurch räumlich +stabil und die Auswahl von Stream, Abschnitt oder Usage ändert nur den Inhalt +derselben Metadatenfläche. Unterhalb der Desktopbreite ist der Inspector ein +eigener `Details`-Pane statt eines überlagernden Dialogs. + +Show und Abschnitt verwenden die vorhandenen Actions. Für Usage-Schnellfelder +führt eine schmale Route-Action die nicht dargestellten Snapshotfelder aus der +aktuellen serverseitigen Projektion mit und ruft anschließend unverändert +`updatePieceUsage()` auf. Damit bleibt die Revision die gemeinsame +Schreibgrenze; ein reduziertes Formular darf keine Moderations-, Recherche- oder +Präsentationsdaten löschen. Der Link `Piece bearbeiten` führt bewusst zum +Original-Piece und vermischt dessen Autosave nicht mit der Streamrevision. + +## D-039: Postproduktion bleibt an abgeschlossene Streams gebunden + +Post erhält kein zweites Sendungsmodell. Jeder Aufzeichnungsteil, jede Quelle und +jeder Post-Inhalt verweist auf eine vorhandene Sendung; der Dienst akzeptiert +ausschließlich `shows.status = 'completed'`. Gemeinsame Identität, Status, +Revision, Herkunft und Reihenfolge liegen in `post_items`, während YouTube, +Social-Clips und Twitch-Highlights ihre unterschiedlichen Felder in typbezogenen +Tabellen führen. Migration `0021` ist damit rein additiv und verändert keine +bestehenden Stream-Snapshots. + +Markdown-Kontext wird unverändert privat gespeichert. SHA-256 schützt pro +Sendung vor Dubletten; Zeitmarken und Transkriptfragmente werden nur aus +tatsächlich vorhandenen Zeilen extrahiert. Quellen und Thumbnails verwenden +zufällige Speichernamen im konfigurierten Uploadverzeichnis und authentifizierte +Auslieferungsrouten. Ein Originalname oder Medienpfad wird nie zu einer +ungeschützten statischen URL. + +Ein Export darf seine technische Verwendbarkeit nicht vortäuschen. Resolve-XML +benötigt daher einen absoluten Pfad oder `file://`, Framerate, Timebase sowie +positive Start- und Endpunkte. SRT wird vollständig blockweise validiert. Fehlt +eine Voraussetzung, antwortet die Exportroute mit `EXPORT_NOT_READY`, und die +Oberfläche bietet den Export nicht an. + +Eine KI-Schreibfunktion wird erst mit einem real konfigurierten Provider +eingeführt. `post-ai.ts` definiert nur den providerneutralen Ein-/Ausgabevertrag; +es gibt keine Scheinaktion, keinen Netzwerkaufruf und keine Abhängigkeit des +manuellen Workflows von einem KI-Dienst. + +## D-040: Gemeinsame Inhaltswerkbank und URL-gebundene Voll-Editoren + +Pieces, Eingang und Post folgen derselben visuellen Hierarchie: Navigation oder +Quellen links, auswählbare Inhalte mittig und häufige Bearbeitung rechts. Der +Inspector ist ausdrücklich kein reines Metadatenpanel; er enthält die im Alltag +benötigten Texte, kontextbezogene Uploads und Primäraktionen. Bei mittleren +Breiten folgt er unter dem Inhalt, mobil werden die Bereiche geordnet gestapelt. +Diese Projektion führt keine neue persistente Auswahl oder Fachlogik ein. +„Gemeinsam“ bedeutet dabei auch gemeinsame Oberflächenbausteine: Katalognavigation, +`builder-pane` und `stream-inspector` werden von Streams, Pieces und Post direkt +geteilt. Bereichsspezifische Parallelimplementierungen mit nur ähnlicher Optik +werden vermieden. + +Die vollständige Piece- und Postbearbeitung behält ihre bestehende eigene URL. +Der Piece-Editor bleibt ein nativer modaler Dialog ohne zweite +Inhaltsnavigation. Die Post-Werkbank ist dagegen eine normale Seite im +Streams-Layout, damit globale Navigation, Streamkontext und der Wechsel zwischen +mehreren YouTube-, Social- und Highlight-Inhalten räumlich stabil bleiben. Sie +ergänzt dafür bewusst eine linke Schnellnavigation. Piece-Autosave, Revisionen, +Post-Revisionen und sämtliche Snapshotgrenzen bleiben die einzigen +Schreibgrenzen. + +RSS bleibt vor der Übernahme eine eigene Entität im Eingang. Eine einheitliche +Darstellung darf diese Grenze nicht verwischen: Vorschau, Verwerfen und +Übernehmen arbeiten auf `feed_entries`; erst der ausdrückliche Import erzeugt ein +Piece. Ebenso projiziert Post ausschließlich vorhandene abgeschlossene Streams +und erhält keine zweite Sendungsverwaltung. + +## D-041: Stream-orientierte Post-Schnellwerkbank und gemeinsame Auswahlflächen + +Die Post-Detailroute startet ohne ausgewählten Einzelinhalt. In diesem Zustand +zeigt die Mitte eine kopierbare Übersicht und der Inspector ausschließlich +Streammetadaten; erst eine Auswahl in der linken Navigation öffnet einen Editor. +Die URL trägt die Auswahl als `item`, und jede Mutation übernimmt diesen +Parameter. Dadurch bleiben Reload, Uploads und Speichern adressierbar, ohne einen +neuen persistenten Auswahlzustand einzuführen. + +TikTok und Instagram teilen eine Caption inklusive Hashtags. YouTube Shorts +führt nur den Titel. Migration `0022` ergänzt dafür additiv `social_caption` und +übernimmt vorhandene Texte. Eine einzige Kontextdatei wird bei der Anlage +automatisch verknüpft. Piece-Optionen werden anhand der tatsächlichen Usages des +geöffneten Streams begrenzt; die Postproduktion kann damit keine fachlich +fremden Fundus-Pieces zuordnen. + +Checkboxen und Radios werden nicht durch eigene JavaScript-Steuerungen ersetzt. +Sie bleiben native, fokussierbare Eingaben, während das Label als farblich +markierte Auswahlfläche erscheint und das Kästchen selbst unsichtbar bleibt. +Diese Regel gilt sendewerkweit und erhält Formsemantik, Tastaturbedienung sowie +serverseitige Formverträge. + +Post verwendet für aktive Inhaltszeilen, Register, Zielplattformen und Filter +dieselbe Auswahl wie Streams: abgerundete ruhige Fläche und dünne goldene +Kontur, ohne Cyan-Füllung oder seitlichen Balken. Felder, Radien und Abstände +werden aus der Streambearbeitung übernommen, statt einen eigenen +Post-Formularstil zu bilden. + +## D-042: Live als vollständig lesende Streamprojektion + +Live ist keine eigene Steuerungsshell mehr, sondern eine reguläre +Sendewerk-Seite. Sie verwendet globale Desktop- und Mobilnavigation, den +Streamkopf sowie drei Pane-Flächen für Ablauf, Moderationsdokument und Details. +Die responsive Umschaltung folgt den übrigen Werkbänken statt eines modalen +Ablauf-Sheets. + +Die Route zeigt keine Status-, Fortschritts-, Sortier- oder Abschlusssteuerung. +Das Auswählen eines Pieces, Vor/Zurück und die Darstellungsoptionen sind +ausschließlich lokaler Browserzustand. Präsentieren bleibt als ausdrücklich +getrennte Ausgabeaktion erhalten und schreibt nur den Präsentationsstand. Dadurch +kann Navigation innerhalb von Live weder den Stream- oder Usage-Status noch die +Reihenfolge verändern. Bestehende Live-Daten werden als gespeicherter Stand +dargestellt. + +## D-043: Verbindliches Seitenraster und reduzierte App-Bewegung + +Streams definiert nicht nur Farben und Pane-Radien, sondern das Seitenraster für +alle Bereiche. Die linke Bereichsspalte beginnt unmittelbar unter der globalen +Navigation und überspannt freien Seitenkopf sowie Inhaltszeile. Der Seitenkopf +liegt ohne eigene Hintergrundfläche im Hauptbereich. Erst darunter beginnen die +mittlere Inhaltsfläche und, falls vorhanden, der rechte Inspector. Gemeinsame +Spaltenbreiten und Abstände dürfen nicht bereichsspezifisch abweichen. + +Der Piece-Voll-Editor bleibt ein erkennbarer modaler Dialog und erhält deshalb +auf Desktop Abstand zum Viewportrand, Kontur, Schatten und sichtbaren Backdrop. +Öffnen, Schließen, Vergrößern beziehungsweise Pane-Wechsel verwenden kurze, +ruhige Bewegungen unter 200 ms. Sie verändern keine Fachzustände und werden bei +reduzierter Bewegung vollständig deaktiviert. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index ab462a0..5d61638 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,141 @@ # Release Notes +## 0.9.0 - Postproduktion + +Veröffentlicht am 29. Oktober 2026, Korrekturstand 30. Oktober 2026 + +- **Post** ergänzt die Hauptnavigation und zeigt alle bestehenden abgeschlossenen + Streams mit Suche, Sortierung, Fortschritt und Statusfilter. Die Streamdaten + bleiben die einzige fachliche Quelle. +- Pieces, Eingang und Post verwenden eine gemeinsame Inhaltswerkbank mit + Navigation beziehungsweise Quellen links, auswählbaren Inhalten in der Mitte + und echter Schnellbearbeitung rechts. Wichtige Texte, Einordnung, passende + Uploads und häufige Aktionen sind ohne Öffnen des Voll-Editors erreichbar. +- Die Korrektur vom 30. Oktober vereinheitlicht auch die konkreten Bauteile: + Post besitzt nun dieselbe einklappbare Katalognavigation wie Streams und + Pieces; Piece-Schnellbearbeitung, Post-Schnellübersicht und Post-Eigenschaften + verwenden den Stream-Inspector samt identischem Formular- und Aktionsaufbau. +- Vollständige Piece- und Postbearbeitung behält die bestehende eigene URL und + folgt zwei passenden Darstellungen: Piece bleibt ein großes gesperrtes + Arbeitsblatt, Post ist eine normale Seite im Streams-Layout. Post besitzt + Streamkopf, Datum und Zurücklink sowie links eine Schnellnavigation für + YouTube, Social-Clips und Twitch-Highlights samt Sofortanlage. Ohne Einzelwahl + zeigt die Mitte eine kopierbare Inhaltsübersicht und rechts stehen + Streammetadaten. +- Streamzeilen und die rechte Öffnen-Aktion führen direkt in die zugehörige + Post-Werkbank. Eine farblich aktive Inhaltszeile öffnet den jeweiligen Editor. +- Checkboxen und Radios erscheinen sendewerkweit als vollständig anklickbare, + wie Streams markierte Auswahlflächen ohne sichtbare Kästchen. Post-Inhaltszeilen, + Register, Filter und RSS-Auswahl verwenden eine abgerundete ruhige Fläche mit + dünner goldener Kontur statt Cyan-Füllung oder Seitenbalken. Native Semantik, + Fokus und Formübermittlung bleiben erhalten. +- Live ist nun eine vollständig lesende Streamprojektion in der normalen + Sendewerk-Shell. Streamkopf, Ablauf, Moderationsdokument und Details folgen der + Streams-Geometrie; Desktop- und Mobilnavigation bleiben sichtbar. Vor/Zurück, + Uhr und Präsentieren bleiben erhalten; Status-, Fortschritts-, Sortier- und + Abschlussaktionen entfallen. Lokale Auswahl und Darstellungsoptionen schreiben + keine Stream- oder Piece-Daten, Präsentieren nur den getrennten Ausgabestand. +- Pieces, Eingang, Post und Live übernehmen auch das Seitenraster von Streams: + linke Navigation ab Oberkante, freier Seitenkopf und erst darunter Inhalts- + und Inspectorflächen mit festen gemeinsamen Spaltenbreiten und Abständen. +- Der Piece-Editor ist wieder klar als Overlay erkennbar und hält auf Desktop + Abstand zum Viewportrand. Sein Öffnen kombiniert einen schnellen räumlichen + Lift mit sanft gestaffeltem Inhalt und Backdrop; Schließen führt dieselbe + Bewegung kompakt zurück und navigiert erst nach ihrem tatsächlichen Ende. + Weitere native Dialoge verwenden dieselbe Bewegungsrichtung. Alle Übergänge + bleiben unter 200 ms; reduzierte Bewegung deaktiviert sie ohne Wartezeit. +- Post-Formfelder, Rundungen und Abstände entsprechen der Streambearbeitung. Die + Überschrift der Übersicht und der linken Detailnavigation lautet vollständig + „Postproduktion“. +- Der Eingang bleibt Sichtungs- und Importbereich. RSS-Vorschau, Original, + Übernehmen und Verwerfen stehen im Inspector; erst die bewusste Übernahme + erzeugt ein normales Piece. +- Aufzeichnungsteile speichern optionale Medienparameter. Mehrere private + Markdown-Kontextdateien behalten ihren Originalinhalt, werden innerhalb einer + Sendung per SHA-256 dedupliziert und nur konservativ nach vorhandenen + Zeitmarken ausgewertet. +- Mehrere YouTube-Uncuts führen Beschreibung, Titelvarianten, Thumbnailideen, + echte auswählbare Thumbnail-Dateien und Veröffentlichungsdaten. +- Social-Clips besitzen Originaltranskripte, einen YouTube-Shorts-Titel, eine + gemeinsame TikTok-/Instagram-Caption inklusive Hashtags, Hooks, Endcards, + Schnitt-, Format- und Untertiteldaten. Twitch-Highlights bilden einen + eigenständigen redaktionellen Typ. +- Bei genau einer Kontextdatei wird sie neuen Post-Inhalten automatisch + zugeordnet. Die Piece-Auswahl ist auf Originale begrenzt, die im geöffneten + Stream tatsächlich verwendet wurden. +- Authentifizierte Datei- und Exportrouten schützen Quellen und Thumbnails. + Resolve-XML und SRT werden nur angeboten und ausgeliefert, wenn die jeweils + erforderlichen Daten vollständig valide sind. +- Es gibt keine simulierte KI-Funktion. Ein providerneutraler Adaptervertrag ist + für eine spätere echte Integration vorbereitet; der manuelle Ablauf ist ohne + externe Dienste vollständig. +- Migration `0021_post_production.sql` ist additiv. Migration + `0022_post_quick_workbench.sql` ergänzt die gemeinsame Social-Caption und + übernimmt vorhandene Plattformtexte. Bestehende Stream-, Piece-, Live- und + Freigabedaten werden nicht verändert. Paket, Lockdatei, Cloudron-Manifest und + Oberfläche tragen Version `0.9.0`. +- Lint, Typprüfung, 69 Verhaltenstests, Produktionsbuild und 22 Chromium-Abläufe + sind erfolgreich. Safari, echte Mobilgeräte, reale Schnittsoftware und eine + echte KI-Anbindung bleiben mangels Umgebung offen. + +## 0.8.2 - Piece-Schreibraum + +Veröffentlicht am 27. Oktober 2026, Korrekturstand 28. Oktober 2026 + +- Einleitung, Moderationsstichpunkte, Interaktion und Überleitung bilden ein + zusammenhängendes, inhaltswachsendes Schreibdokument. Nur der fokussierte + Abschnitt zeigt Formatierungswerkzeuge; Tiptap, Auswahl erhaltende Befehle und + Markdown-Quellansicht bleiben erhalten. +- Im geöffneten Editor wird die linke Bereichsnavigation zur gefilterten + Piece-Liste mit Suche, denselben Filtern, markiertem aktuellem Piece und + zustandserhaltendem Rücksprung. Ein Wechsel läuft über bestehenden Autosave und + Revisionsschutz. Übersichtsseiten behalten ihre bisherige Navigation. +- Status, Dauer, Format und Themen sind rechts kompakt direkt bearbeitbar; + Kurzbeschreibung, Aufwand und Kategorien liegen unter „Weitere Details“. + Text, Nur Bild, Linkvorschau und Video sowie die direkt editierbare + Publikumsvorschau bleiben darunter sichtbar. +- Der reduzierte Kopf bündelt Favorit, Speicherzustand, manuelles Speichern und + ein abgesichertes Aktionsmenü. Speicher- und Aktionssteuerung stehen unter dem + Titel, sodass lange Überschriften auch bei schmalem Desktop Platz behalten. Die + dauerhafte Editor-Fußleiste und doppelte Metadaten entfallen. +- Mobil öffnen Streamtext, Recherche, Piece-Liste und Details/Ausgabe als + getrennte Ansichten. Der Piece-Drawer ist schließbar; jeder Textbereich besitzt + einen touchfähigen Zugang zu seinen Werkzeugen. Die globale Hauptnavigation + bleibt unten. +- Autosave, Revisionsschutz, Quellen, Uploads, Testfenster, Vorlagen, Favorit, + Duplikation, Archivierung und Löschung behalten ihre bisherigen Actions. + Streamfassungen und Snapshots, Live-Regie und Suche bleiben fachlich + unverändert. Es gibt keine Datenbankmigration. +- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.2`. +- Die Desktopgeometrie füllt die mittlere Spalte und wächst beim Einklappen der + Piece-Liste. Der Textsatz bleibt innerhalb der vollbreiten Dokumentfläche + lesbar; die rechte Spalte zeigt die Publikumsvorschau statt einer langen + Formularmatrix. +- Eine Korrektur für mittlere Desktopbreiten entfernt die beim Einklappen + verbliebene leere Rasterspalte. Größere Navigations- und Piece-Icons, eine + überlappungsfreie kompakte Kopfnavigation und das sichtbare Versionslabel + `0.8.2` verbessern die Orientierung. +- Die Piece-Markierung bleibt mit eigenem Scrollgutter bündig. +- Der Streamplan zeigt rechts dauerhaft einen Inspector in der Breite der + Piece-Metadaten. Standardmäßig enthält er Streamdaten und Twitch-Texte; ein + Klick auf Abschnitt oder Ablauf-Piece zeigt die passenden Schnellfelder. + Piece-Auswahlen enden mit `Piece bearbeiten` und `Speichern`, weitere + bestehende Usage-Aktionen bleiben eingeklappt erreichbar. +- Die Piece-Bibliothek schließt bei einer Inhaltsauswahl automatisch, damit + Ablauf und Inspector nebeneinander lesbar bleiben. Schmalere Ansichten + verwenden dafür den zusätzlichen Arbeitsbereich `Details`. +- Das neue Usage-Metadatenformular führt nicht dargestellte Langtexte, + Präsentationswerte, Medien und Werkzeuge serverseitig unverändert mit und + bleibt revisionsgeschützt. +- Die Live-Regie verwendet die neue ruhige Dokument- und Ablaufsprache: aktuelle + Moderationstexte liegen in einem zusammenhängenden Lesedokument, der Ablauf ist + flacher und Aktionszeilen überlagern sich nicht. Vollbildshell, Timer, + Präsentationsgrenze, mobile Ablaufansicht und sämtliche Live-Actions bleiben + unverändert. +- Lint, Typprüfung, 64 Verhaltenstests, Produktionsbuild und 22 + Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben + mangels Umgebung offen. + ## 0.8.1 - Redaktionelle Werkbänke Veröffentlicht am 27. Oktober 2026 diff --git a/docs/STATUS.md b/docs/STATUS.md index de31c97..bc0bc44 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,6 +8,179 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie, Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind dokumentiert. +## Einheitliche Inhaltswerkbänke 0.9.0 + +- Pieces, Eingang und Post verwenden dieselbe Werkbankhierarchie aus linker + Navigation beziehungsweise Quellenwahl, mittlerer Inhaltsliste und rechter + Schnellbearbeitung. Bei mittleren Breiten folgt der Inspector unter dem Inhalt; + mobil werden die drei Bereiche in derselben fachlichen Reihenfolge gestapelt. +- Pieces und Post verwenden dabei nicht nur dasselbe Raster, sondern direkt die + Stream-Komponentensprache: einklappbare Katalognavigation, `builder-pane`, + `stream-inspector`, identische Feldabstände sowie dieselben festen Aktionsleisten. + Der Post-Voll-Editor kombiniert entsprechend Schnellnavigation, Schreibpane + und Stream-artigen Eigenschaften-Inspector ohne abweichende Parallelbausteine. +- Die Piece-Übersicht wählt Inhalte zunächst in der Liste aus. Der rechte + Inspector bearbeitet Titel, Kurzbeschreibung, Einleitung, Moderation, Status, + Dauer und Format revisionsgeschützt und bietet den kontextbezogenen Bild-Upload. +- Der Eingang bleibt eine getrennte Sichtungsgrenze. RSS-Beiträge erhalten rechts + Vorschau, Originallink sowie Übernehmen oder Verwerfen; erst die Übernahme + erzeugt ein normales Piece. Bereits redaktionelle Eingangspieces lassen sich + dort schnell benennen, beschreiben, löschen oder vollständig öffnen. +- Die Post-Übersicht wählt ausschließlich abgeschlossene Streams und zeigt deren + vorhandenen Arbeitsstand im Inspector. Ein Klick auf die Streamzeile oder die + Öffnen-Aktion führt direkt in die zugehörige Werkbank. Es entsteht weder ein + zweites Sendungsmodell noch eine zweite Abschlusslogik. +- Die vollständige Piece-Bearbeitung bleibt unter ihrer eigenen URL ein nativer + großer Modaldialog. Die Post-Bearbeitung ist dagegen eine normale Seite im + Streams-Layout mit globaler Navigation, Streamkopf und Zurücklink. Links steht + die dauerhafte Schnellnavigation für YouTube, Social-Clips und + Twitch-Highlights mit Sofortanlage. Ohne konkrete Inhaltsauswahl zeigt die + Mitte alle vorhandenen Post-Inhalte mit Kopieraktionen und rechts die + Streamzusammenfassung. +- Der Piece-Autosave synchronisiert separate Medienmutationen in den lokalen + Entwurf. Radio- und Checkboxänderungen lösen den revisionsgeschützten Save aus; + der Bildmodus wird erst nach bewusst ausgewähltem Präsentationsmedium gespeichert. +- Checkboxen und Radios bleiben semantische, fokussierbare Formelemente, werden + sendewerkweit aber als vollständig anklickbare Auswahlflächen ohne sichtbares + Kästchen dargestellt. Aktive Auswahl übernimmt exakt die Streams-Sprache aus + abgerundeter ruhiger Fläche und dünner goldener Kontur; Cyan-Füllung und + seitlicher Auswahlbalken entfallen. +- Live ist eine reguläre Sendewerk-Seite mit globaler Desktop- und + Mobilnavigation, Streamkopf und drei Stream-artigen Pane-Flächen für Ablauf, + Lesedokument und Details. Vor/Zurück, laufende Uhr und die fachlich getrennte + Präsentationsausgabe bleiben verfügbar. Status-, Fortschritts-, Sortier- und + Abschlussaktionen fehlen. Piece-Auswahl, Schriftgröße und Zeitanzeige sind + lokale Darstellungszustände und verändern weder Streamstatus noch + Piece-Verwendungen oder Reihenfolge. +- Das gemeinsame Seitenraster folgt jetzt unmittelbar Streams: Die linke + Bereichsspalte beginnt direkt unter der App-Navigation und reicht über die + Seitenhöhe. Überschrift und Kontext stehen ohne umgebende Pane-Fläche rechts + daneben; erst darunter beginnen mittlere Inhaltsfläche und optionaler rechter + Inspector. Pieces, Eingang, Post und Live verwenden dieselben Spaltenbreiten, + Abstände und responsiven Übergänge. +- Der Piece-Editor bleibt auf Desktop und schmalem Desktop sichtbar ein Overlay + mit Abstand zum Viewportrand, abgerundeter Kontur, Schatten und erkennbarem + Hintergrund. Öffnen verwendet einen räumlichen Lift mit dezent gestaffeltem + Inhalt, Schließen die schnelle Gegenbewegung. Die Navigation wartet auf das + tatsächliche Animationsende statt auf eine feste Verzögerung. Dialog-, Pane- + und Disclosure-Bewegungen bleiben unter 200 ms; `prefers-reduced-motion` + schaltet sie ohne Wartezeit vollständig ab. + +Geprüft wurden Lint, Typprüfung, Produktionsbuild, 69 von 69 Verhaltenstests und +22 von 22 Chromium-Abläufe. Die responsive Sichtmatrix umfasst die drei +Übersichten bei 1440 × 1000, 960 × 900 und 390 × 844 Pixeln sowie Piece- und +Post-Detailseite. Die inhaltslose Post-Detailübersicht und ihre mobile +Schnellnavigation wurden zusätzlich aufgenommen und visuell kontrolliert. +Escape, sichtbares Schließen, Browser-Zurück, Autosave nach Medienauswahl, +Auswahlflächen und horizontaler Überlauf wurden gezielt geprüft. Live wurde bei +1440, 960 und 390 Pixeln visuell kontrolliert; ein abgewiesener POST-Versuch +sowie Auswahl und Darstellungswechsel ließen Show-Status, Usage-Status und +Reihenfolge unverändert. Safari und echte Mobilgeräte bleiben mangels Umgebung +offen. + +## Postproduktion 0.9.0 + +- Der neue Hauptbereich **Post** zeigt ausschließlich vorhandene abgeschlossene + Streams. Suche, Sortierung, Fortschrittsfilter und Zähler werden aus denselben + Sendungs- und Postdaten abgeleitet; es gibt keine zweite Sendungsverwaltung. +- Die vollständige Detailansicht ist eine normale Seite im Layout der + Streambearbeitung: Streamtitel und Datum stehen im Seitenkopf, darunter folgen + linke Schnellnavigation, mittlere Schreib- beziehungsweise Übersichtsfläche + und rechter Eigenschaften-Inspector. YouTube, Social-Clips und + Twitch-Highlights lassen sich links sofort anlegen; mobil sind Inhalte, + Bearbeiten und Details getrennte Pane-Zustände. +- Formfelder, Rundungen und Abstände entsprechen den Streamformularen. Aktive + Post-Inhalte, Register, Zielplattformen und Filter verwenden wie Streams eine + ruhige abgerundete Fläche mit dünner goldener Kontur. +- Pro Stream lassen sich mehrere Aufzeichnungsteile mit Medienpfad, Framerate und + Timebase sowie mehrere unveränderte Markdown-Kontextdateien verwalten. + SHA-256 verhindert Dubletten innerhalb einer Sendung; Zeitmarken und + Transkriptfragmente werden ausschließlich konservativ aus vorhandenen Zeilen + extrahiert. +- YouTube-Uncuts, Social-Clips und Twitch-Highlights sind eigenständige, + revisionierte Post-Elemente. YouTube führt Beschreibung, Titelvarianten, + Thumbnailkonzept, echte private Thumbnail-Dateien und Veröffentlichungsdaten. + Clips führen Originaltranskript, einen reinen YouTube-Shorts-Titel, eine + gemeinsame TikTok-/Instagram-Caption inklusive Hashtags, Hooks, Endcards, + Zielplattformen, Untertitel und Schnittdaten; Highlights bleiben unabhängig. +- Bei genau einer Kontextdatei ordnet die Anlage sie automatisch zu. Die + Piece-Auswahl enthält ausschließlich Original-Pieces, die im geöffneten Stream + tatsächlich verwendet wurden. +- Quellen und Thumbnails liegen im konfigurierten privaten Uploadverzeichnis und + werden nur über authentifizierte Routen ausgeliefert. Originalnamen werden + nicht zu Speicherpfaden. +- Text- und JSON-Exporte stehen für vorhandene Inhalte bereit. DaVinci-Resolve-XML + erscheint nur bei absolutem Medienpfad, Framerate, Timebase und gültigem + Zeitbereich; SRT nur nach vollständiger blockweiser Validierung. +- `post-ai.ts` beschreibt lediglich einen providerneutralen späteren Adapter. + Ohne konfigurierte KI bleiben Import und sämtliche manuellen Arbeitsschritte + vollständig nutzbar; die Oberfläche täuscht keine Generierung vor. +- Migration `0021_post_production.sql` ergänzt ausschließlich Posttabellen; + `0022_post_quick_workbench.sql` ergänzt die gemeinsame Social-Caption und + übernimmt vorhandene Caption-/Hashtag-Texte. Der Seed enthält eine + abgeschlossene Beispielsendung, und Paket, Lockdatei, Oberfläche sowie + Cloudron-Manifest tragen Version `0.9.0`. + +Geprüft wurden Lint, Typprüfung, Produktionsbuild, 69 von 69 Verhaltenstests und +22 von 22 Chromium-Abläufe. Der vollständige Post-Rundlauf umfasst Material, +Markdown, YouTube, Thumbnail, Social-Clip, echten Registerklick, Resolve-/SRT- +Export, direkte Streamöffnung, inhaltslose Übersicht und mobile Schnellnavigation. +Safari, echte Mobilgeräte, reale Schnittsoftware und eine KI-Anbindung bleiben +mangels Umgebung offen. + +## Piece-Schreibraum 0.8.2 + +- Einleitung, Moderationsstichpunkte, Interaktion und Überleitung bleiben getrennt + gespeichert, erscheinen aber als ein zusammenhängendes Schreibdokument mit + ruhigen Abschnittstrennern und inhaltswachsender Höhe. Nur der fokussierte + Abschnitt zeigt seine Tiptap-Werkzeuge; Auswahl und Fokus bleiben beim + Formatieren erhalten, die Markdown-Quellansicht bleibt erreichbar. +- Die linke Spalte ist kontextsensitiv: Übersichtsseiten behalten ihre bisherige + Bereichsnavigation, der geöffnete Editor zeigt Rücksprung, dieselben Filter, + Suche und die aktuelle gefilterte Piece-Liste. Normale Links verwenden den + vorhandenen Autosave-/Konfliktablauf; die aktuelle Auswahl ist markiert. +- Rechts sind Status, Dauer, Format und Themen direkt kompakt bearbeitbar. + Kurzbeschreibung, Vorbereitungsaufwand und Kategorien liegen unter „Weitere + Details“. Darunter sind Moduswahl und die direkt editierbare + Publikumsvorschau bereits in üblicher Fensterhöhe sichtbar. +- Der Kopf enthält nur internen Titel, Favorit, kompakten Speicherzustand, + manuelles Speichern und ein Aktionsmenü für Vorlagen, Duplizieren, Archivieren + und Löschen. Speicher- und Aktionssteuerung stehen in einer eigenen Zeile unter + dem Titel; die permanente Fußleiste und doppelte Metadaten entfallen. +- Mobil bleiben Streamtext, Recherche, Piece-Liste und Details/Ausgabe getrennte + Ansichten. Der Piece-Drawer ist opak, schließbar und Safe-Area-fähig; + Formatierungswerkzeuge sind über „Formatieren“ ohne Hover erreichbar. +- Bei mittleren Desktopbreiten gibt die eingeklappte Piece-Navigation ihren Platz + vollständig an den Editor frei. Die globale Navigation wechselt dort ohne + Überlagerungen auf größere, eindeutig beschriftete Icons; die sichtbare Marke + weist den Stand `0.8.2` aus. +- Die aktuelle Piece-Markierung hält rechts Abstand zum stabilen Scrollgutter. +- Der Streamplan besitzt rechts dauerhaft einen kompakten Inspector in derselben + Breitenklasse wie die Piece-Metadaten. Ohne Auswahl zeigt er Streamdaten und + Twitch-Texte; ein Klick auf Abschnitt/Kategorie oder Ablauf-Piece wechselt zu + dessen direkt speicherbaren Metadaten. Beim Piece bleiben `Piece bearbeiten` + und `Speichern` als feste Abschlussaktionen sichtbar; seltene Usage-Aktionen + liegen unter `Weitere Aktionen`. +- Eine Inhaltsauswahl schließt die nur temporär benötigte Piece-Bibliothek und + hält dadurch Ablauf und Inspector lesbar. Unterhalb der Desktopbreite ergänzt + `Details` die bestehende Umschaltung zwischen Pieces und Ablauf; direkte + Vorbereitungslinks öffnen sofort die richtige Streamfassung. +- Die Live-Regie übernimmt die ruhigen Dokument-, Status- und Ablaufoberflächen + der neuen Arbeitsbereiche. Timer, lokale Auswahl, tatsächliches Live-Piece, + Präsentation, Ablauf-Sheet und feste Abschlussaktionen bleiben funktional + unverändert. +- Autosave, Revisionskonflikte, Medien, Quellen, Werkzeuge, Präsentationswerte, + Favorit und getrennte Streamfassungen behalten ihre Persistenzgrenzen. Das + Usage-Schnellformular führt unveränderte Langtexte serverseitig mit und kann + sie deshalb nicht durch fehlende Formularfelder leeren. Es gibt keine + Datenbankmigration und keine Änderung an Live-Regie, Suche, API oder Rechten. + +Geprüft wurden Lint, Typprüfung, Produktionsbuild, 64 von 64 Verhaltenstests und +22 von 22 Chromium-Abläufe. Die visuelle Matrix umfasst gefüllte und leere Pieces, +Streamfassung und Live-Regie bei 1440/1280 × 900/1000, einen schmalen Desktop bei +960 × 900, kompakte Live-Fenster bei 900/670 × 450 sowie mobile Piece- und +Live-Ansichten. Safari und echte Mobilgeräte bleiben mangels Umgebung offen. + ## Redaktionelle Werkbänke 0.8.1 - Der Piece-Editor führt ohne vorgeschaltete Leerfläche direkt vom internen diff --git a/migrations/0021_post_production.sql b/migrations/0021_post_production.sql new file mode 100644 index 0000000..41a5935 --- /dev/null +++ b/migrations/0021_post_production.sql @@ -0,0 +1,127 @@ +CREATE TABLE post_recording_parts ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + label TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + media_file_path TEXT NOT NULL DEFAULT '', + frame_rate REAL CHECK (frame_rate IS NULL OR (frame_rate > 0 AND frame_rate <= 240)), + timebase INTEGER CHECK (timebase IS NULL OR (timebase > 0 AND timebase <= 240)), + created_at_utc TEXT NOT NULL, + UNIQUE (show_id, position) +); + +CREATE INDEX idx_post_recording_parts_show + ON post_recording_parts(show_id, position); + +CREATE TABLE post_source_files ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + recording_part_id TEXT REFERENCES post_recording_parts(id) ON DELETE SET NULL, + original_name TEXT NOT NULL, + storage_name TEXT NOT NULL UNIQUE, + sha256 TEXT NOT NULL, + byte_size INTEGER NOT NULL CHECK (byte_size > 0), + content TEXT NOT NULL, + transcript_text TEXT NOT NULL DEFAULT '', + timecodes_json TEXT NOT NULL DEFAULT '[]', + imported_at_utc TEXT NOT NULL, + UNIQUE (show_id, sha256) +); + +CREATE INDEX idx_post_source_files_show + ON post_source_files(show_id, imported_at_utc); + +CREATE TABLE post_items ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('youtube', 'clip', 'highlight')), + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('draft', 'review', 'approved', 'editing', 'done', 'published')), + recording_part_id TEXT REFERENCES post_recording_parts(id) ON DELETE SET NULL, + source_file_id TEXT REFERENCES post_source_files(id) ON DELETE SET NULL, + piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL, + notes TEXT NOT NULL DEFAULT '', + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + position INTEGER NOT NULL CHECK (position >= 0), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL, + UNIQUE (show_id, kind, position) +); + +CREATE INDEX idx_post_items_show_kind + ON post_items(show_id, kind, position); + +CREATE TABLE post_youtube_videos ( + item_id TEXT PRIMARY KEY REFERENCES post_items(id) ON DELETE CASCADE, + description TEXT NOT NULL DEFAULT '', + title_variants_json TEXT NOT NULL DEFAULT '[]', + thumbnail_concept TEXT NOT NULL DEFAULT '', + thumbnail_text TEXT NOT NULL DEFAULT '', + publication_status TEXT NOT NULL DEFAULT 'unpublished' + CHECK (publication_status IN ('unpublished', 'scheduled', 'published')), + published_at_utc TEXT, + youtube_url TEXT NOT NULL DEFAULT '', + video_format TEXT NOT NULL DEFAULT '16:9' +); + +CREATE TABLE post_social_clips ( + item_id TEXT PRIMARY KEY REFERENCES post_items(id) ON DELETE CASCADE, + clip_type TEXT NOT NULL DEFAULT 'other' + CHECK (clip_type IN ('storytime', 'opinion', 'explanation', 'gaming', 'humor', 'other')), + original_transcript TEXT NOT NULL DEFAULT '', + start_seconds INTEGER CHECK (start_seconds IS NULL OR start_seconds >= 0), + end_seconds INTEGER CHECK (end_seconds IS NULL OR end_seconds >= 0), + dramaturgy_notes TEXT NOT NULL DEFAULT '', + edit_instructions TEXT NOT NULL DEFAULT '', + shorts_title TEXT NOT NULL DEFAULT '', + shorts_description TEXT NOT NULL DEFAULT '', + shorts_hashtags TEXT NOT NULL DEFAULT '', + tiktok_caption TEXT NOT NULL DEFAULT '', + instagram_caption TEXT NOT NULL DEFAULT '', + social_hashtags TEXT NOT NULL DEFAULT '', + primary_hook TEXT NOT NULL DEFAULT '', + alternative_hooks TEXT NOT NULL DEFAULT '', + endcard_text TEXT NOT NULL DEFAULT '', + alternative_endcards TEXT NOT NULL DEFAULT '', + framing_notes TEXT NOT NULL DEFAULT '', + subtitle_notes TEXT NOT NULL DEFAULT '', + subtitle_srt TEXT NOT NULL DEFAULT '', + video_format TEXT NOT NULL DEFAULT '9:16', + target_tiktok INTEGER NOT NULL DEFAULT 1 CHECK (target_tiktok IN (0, 1)), + target_instagram INTEGER NOT NULL DEFAULT 1 CHECK (target_instagram IN (0, 1)), + target_shorts INTEGER NOT NULL DEFAULT 1 CHECK (target_shorts IN (0, 1)), + CHECK (end_seconds IS NULL OR start_seconds IS NULL OR end_seconds > start_seconds) +); + +CREATE TABLE post_twitch_highlights ( + item_id TEXT PRIMARY KEY REFERENCES post_items(id) ON DELETE CASCADE, + description TEXT NOT NULL DEFAULT '', + original_transcript TEXT NOT NULL DEFAULT '', + start_seconds INTEGER CHECK (start_seconds IS NULL OR start_seconds >= 0), + end_seconds INTEGER CHECK (end_seconds IS NULL OR end_seconds >= 0), + edit_instructions TEXT NOT NULL DEFAULT '', + publication_notes TEXT NOT NULL DEFAULT '', + video_format TEXT NOT NULL DEFAULT '16:9', + twitch_url TEXT NOT NULL DEFAULT '', + CHECK (end_seconds IS NULL OR start_seconds IS NULL OR end_seconds > start_seconds) +); + +CREATE TABLE post_thumbnails ( + id TEXT PRIMARY KEY, + youtube_item_id TEXT NOT NULL REFERENCES post_youtube_videos(item_id) ON DELETE CASCADE, + storage_name TEXT NOT NULL UNIQUE, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + byte_size INTEGER NOT NULL CHECK (byte_size > 0), + alt_text TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL CHECK (position >= 0), + is_selected INTEGER NOT NULL DEFAULT 0 CHECK (is_selected IN (0, 1)), + created_at_utc TEXT NOT NULL, + UNIQUE (youtube_item_id, position) +); + +CREATE INDEX idx_post_thumbnails_item + ON post_thumbnails(youtube_item_id, position); + +CREATE UNIQUE INDEX idx_post_one_selected_thumbnail + ON post_thumbnails(youtube_item_id) WHERE is_selected = 1; diff --git a/migrations/0022_post_quick_workbench.sql b/migrations/0022_post_quick_workbench.sql new file mode 100644 index 0000000..86ba9c1 --- /dev/null +++ b/migrations/0022_post_quick_workbench.sql @@ -0,0 +1,15 @@ +ALTER TABLE post_social_clips + ADD COLUMN social_caption TEXT NOT NULL DEFAULT ''; + +UPDATE post_social_clips +SET social_caption = trim( + CASE + WHEN trim(tiktok_caption) != '' THEN trim(tiktok_caption) + ELSE trim(instagram_caption) + END || + CASE + WHEN trim(social_hashtags) != '' THEN char(10) || char(10) || trim(social_hashtags) + ELSE '' + END +) +WHERE social_caption = ''; diff --git a/package-lock.json b/package-lock.json index 6365b81..f3f6027 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.8.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@node-rs/argon2": "^2.2.2", "@tiptap/core": "^3.32.0", diff --git a/package.json b/package.json index 7fb464b..fb1bb7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.8.1", + "version": "0.9.0", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 6adba4f..1219953 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -296,6 +296,17 @@ export function seed(databasePath = getPaths().database) { 1, timestamp, timestamp + ], + [ + 'show-completed-demo', + 'Frühere Sendung: Linux, Spiele und Netzkultur', + '2026-10-09T18:00:00.000Z', + 'Linux, Spiele und Netzkultur | heinzlive', + 'Eine abgeschlossene Beispielsendung für die Postproduktion.', + 'completed', + 1, + timestamp, + timestamp ] ] ); @@ -320,7 +331,8 @@ export function seed(databasePath = getPaths().database) { ['games-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0], ['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 0], ['games-second', 'show-games-evening', 'Spiel 2', 'cat-game', 3, 2700, 0], - ['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1] + ['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1], + ['reserve-bank-show-completed-demo', 'show-completed-demo', 'Reservebank', null, 0, null, 1] ] ); @@ -447,6 +459,21 @@ export function seed(databasePath = getPaths().database) { )` ) .run(); + const hasCompletionSchema = database + .prepare("SELECT 1 FROM pragma_table_info('shows') WHERE name = 'completed_at_utc'") + .get(); + if (hasCompletionSchema) { + database + .prepare("UPDATE shows SET completed_at_utc = ? WHERE id = 'show-completed-demo'") + .run('2026-10-10T00:00:00.000Z'); + database + .prepare( + `INSERT OR IGNORE INTO show_finalizations + (show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc) + VALUES ('show-completed-demo', 'seed-completed-demo', 1, NULL, ?)` + ) + .run('2026-10-10T00:00:00.000Z'); + } }); seedDatabase(); diff --git a/src/app.css b/src/app.css index b8b3691..9dad4e1 100644 --- a/src/app.css +++ b/src/app.css @@ -54,8 +54,8 @@ .fundus-editor-surface .piece-script-panel > .piece-writing-stack { grid-row: 1 / span 2; - width: min(100%, 48rem); - justify-self: center; + width: 100%; + justify-self: stretch; } .fundus-editor-surface .piece-writing-stack { @@ -403,6 +403,253 @@ } } +/* 0.9.0 post quick navigation and selection language. */ +.post-library-workspace .post-page-heading { + align-items: flex-end; + margin-bottom: 0.9rem; + padding: 0 0 0.9rem; +} + +.post-library-workspace .post-page-heading h1 { + margin: 0; + font-size: clamp(1.45rem, 2vw, 1.9rem); +} + +.post-library-heading, +.post-library-heading > div, +.post-nav-group > header, +.post-overview-group > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; +} + +.post-library-heading { + margin-bottom: 0.75rem; +} + +.post-library-heading > div { + align-items: baseline; +} + +.post-library-heading h2, +.post-overview-group h3 { + margin: 0; +} + +.post-library-heading > span, +.post-overview-group > header > span { + padding: 0.25rem 0.55rem; + border-radius: 999px; + background: var(--panel-strong); + color: var(--text-muted); + font-size: 0.7rem; +} + +.post-workspace.post-editor-overlay { + grid-template-columns: minmax(14rem, 17rem) minmax(0, 1fr) clamp(19rem, 23vw, 22rem); +} + +.post-editor-overlay > .post-navigation, +.post-editor-overlay > .post-editor, +.post-editor-overlay > .post-inspector { + border: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line)); + border-radius: var(--radius-medium); + box-shadow: inset 0 1px rgb(255 255 255 / 7%); +} + +.post-editor-overlay > .post-navigation { + padding: 0; + overflow: hidden; + border-right: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line)); + background: color-mix(in srgb, var(--panel-strong) 96%, transparent); +} + +.post-editor-overlay .post-navigation > header { + min-height: 4.3rem; + background: transparent; +} + +.post-nav-group { + padding-bottom: 0.7rem; +} + +.post-nav-group > header { + padding: 0.35rem 0.3rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 760; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.post-nav-group > header span { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.post-nav-group > header b { + color: var(--text-faint); +} + +.post-quick-create button { + display: flex; + width: 100%; + align-items: center; + gap: 0.45rem; + justify-content: flex-start; + padding: 0.55rem 0.6rem; + border: 1px dashed color-mix(in srgb, var(--gold) 48%, var(--line)); + border-radius: 0.62rem; + background: transparent; + color: var(--gold-strong); + font-size: 0.72rem; + font-weight: 720; +} + +.post-content-overview { + gap: 0.85rem; +} + +.post-overview-group { + display: grid; + gap: 0.65rem; + padding: 0.9rem; + border: 1px solid color-mix(in srgb, var(--gold) 16%, var(--line)); + border-radius: var(--radius-medium); + background: var(--panel-strong); +} + +.post-overview-items { + display: grid; + gap: 0.45rem; +} + +.post-overview-items article { + display: grid; + gap: 0.45rem; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: 0.68rem; + background: transparent; +} + +.post-overview-items article > p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.post-overview-open { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; +} + +.post-overview-open small { + color: var(--text-faint); +} + +.post-overview-copy-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.post-overview-copy-actions button { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.5rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: transparent; + color: var(--accent-text); + font-size: 0.68rem; +} + +.post-editor-overlay :is(.post-writing-section, .post-editor-section, .post-overview-group), +.post-create-menu > div, +.post-material-form { + background: var(--panel-strong); +} + +.post-editor-overlay .post-editor-scroll :is(input, select, textarea), +.post-editor-overlay .post-inspector :is(input, select, textarea), +.post-editor-overlay .post-editor-context-toolbar select, +.post-create-menu input { + background: color-mix(in srgb, var(--panel-strong) 97%, var(--gold) 3%); +} + +/* Checkbox and radio semantics remain accessible, but selection is shown by the surface. */ +label:has(> input[type='checkbox']), +label:has(> input[type='radio']) { + position: relative; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: transparent; + cursor: pointer; +} + +label:has(> input[type='checkbox']:checked), +label:has(> input[type='radio']:checked) { + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel-strong)); + box-shadow: none; + color: var(--text); +} + +label > input[type='checkbox'], +label > input[type='radio'] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; + opacity: 0; +} + +label:has(> input[type='checkbox']:focus-visible), +label:has(> input[type='radio']:focus-visible) { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.inline-check, +.filter-checkbox, +.editor-filter-check { + min-height: 2.35rem; + align-items: center; + padding: 0.5rem 0.65rem; +} + +@media (max-width: 72rem) { + .post-workspace.post-editor-overlay { + display: block; + } + + .post-editor-overlay > .post-navigation, + .post-editor-overlay > .post-editor, + .post-editor-overlay > .post-inspector { + border: 0; + border-radius: 0; + } +} + @media (max-width: 52rem) { .fundus-editor-surface .piece-script-panel > .piece-writing-stack { width: 100%; @@ -459,6 +706,862 @@ } } +/* Postproduktion reuses the editorial three-column workbench. */ +.post-overview-page { + width: min(100%, 92rem); +} + +.post-overview-heading { + align-items: flex-end; +} + +.post-overview-filters { + display: grid; + grid-template-columns: minmax(16rem, 1fr) minmax(11rem, 0.55fr) minmax(11rem, 0.55fr) auto; + align-items: end; + gap: 0.7rem; + margin-bottom: 1.25rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--glass) 92%, transparent); + box-shadow: var(--shadow-soft); +} + +.post-show-library > header, +.post-show-row, +.post-show-counts, +.post-show-counts > div { + display: flex; + align-items: center; +} + +.post-show-library > header { + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.post-show-library h2, +.post-show-row h3 { + margin: 0; +} + +.post-show-library > header > span { + padding: 0.3rem 0.65rem; + border-radius: 2rem; + background: var(--material-deep); + color: var(--text-muted); +} + +.post-show-list { + display: grid; + gap: 0.45rem; +} + +.post-show-row { + min-width: 0; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: var(--shadow-soft); +} + +.post-show-row:hover { + border-color: color-mix(in srgb, var(--gold) 48%, var(--line)); + background: color-mix(in srgb, var(--gold) 5%, var(--panel)); +} + +.post-show-primary { + min-width: 15rem; + flex: 1; +} + +.post-show-primary p { + margin: 0.2rem 0 0; + color: var(--text-muted); + font-size: 0.78rem; +} + +.post-progress { + color: var(--gold-strong); + font-size: 0.65rem; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.post-show-counts { + gap: clamp(0.75rem, 2vw, 1.6rem); + margin: 0; +} + +.post-show-counts > div { + min-width: 3.5rem; + flex-direction: column-reverse; +} + +.post-show-counts dt { + color: var(--text-faint); + font-size: 0.64rem; +} + +.post-show-counts dd { + margin: 0; + font-size: 1rem; + font-weight: 740; +} + +.post-workspace { + display: grid; + height: calc(100dvh - 4.25rem); + min-height: 36rem; + grid-template-columns: minmax(15rem, 17rem) minmax(0, 1fr) clamp(19rem, 23vw, 22rem); + overflow: hidden; +} + +.post-workspace.left-collapsed { + grid-template-columns: 3.5rem minmax(0, 1fr) clamp(19rem, 23vw, 22rem); +} + +.post-workspace.right-collapsed { + grid-template-columns: minmax(15rem, 17rem) minmax(0, 1fr) 3.5rem; +} + +.post-workspace.left-collapsed.right-collapsed { + grid-template-columns: 3.5rem minmax(0, 1fr) 3.5rem; +} + +.post-navigation, +.post-inspector, +.post-editor { + min-width: 0; + min-height: 0; +} + +.post-navigation, +.post-inspector { + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--glass) 94%, var(--bg)); +} + +.post-navigation { + border-right: 1px solid var(--line); +} + +.post-inspector { + border-left: 1px solid var(--line); +} + +.post-navigation > header, +.post-inspector > header, +.post-editor-header { + display: flex; + min-height: 4.3rem; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + padding: 0.7rem 0.8rem; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--glass-chrome) 96%, transparent); +} + +.post-navigation > header h1, +.post-inspector > header h2, +.post-editor-header h2 { + margin: 0.1rem 0 0; + font-size: 1rem; + line-height: 1.2; +} + +.post-navigation > header > a, +.post-navigation > header > button, +.post-inspector > header > button { + display: grid; + width: 2.2rem; + height: 2.2rem; + flex: none; + place-items: center; + padding: 0; +} + +.post-navigation-body, +.post-inspector-scroll, +.post-editor-scroll { + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.post-navigation-body { + display: grid; + align-content: start; + gap: 0.65rem; + padding: 0.75rem; +} + +.post-material-button, +.post-item-search { + display: flex; + align-items: center; + gap: 0.45rem; + width: 100%; +} + +.post-material-button { + justify-content: flex-start; + padding: 0.65rem 0.7rem; + border-color: color-mix(in srgb, var(--gold) 32%, var(--line)); + background: color-mix(in srgb, var(--gold) 8%, var(--material-inset)); +} + +.post-material-button span { + flex: 1; + text-align: left; +} + +.post-material-button b, +.post-nav-group summary b { + color: var(--text-faint); + font-size: 0.7rem; +} + +.post-item-search { + padding: 0.1rem 0.6rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--field); +} + +.post-item-search input { + min-width: 0; + padding-inline: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.post-nav-group { + border-bottom: 1px solid var(--line); + padding-bottom: 0.5rem; +} + +.post-nav-group > summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.3rem; + font-size: 0.72rem; + font-weight: 760; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.post-nav-group > summary span { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.post-nav-group > div { + display: grid; + gap: 0.18rem; +} + +.post-nav-group > div > button { + display: grid; + gap: 0.12rem; + justify-items: start; + width: 100%; + padding: 0.55rem 0.6rem; + border-color: transparent; + background: transparent; + text-align: left; +} + +.post-nav-group > div > button.active { + border-color: color-mix(in srgb, var(--gold) 40%, var(--line)); + background: color-mix(in srgb, var(--gold) 11%, var(--material-inset)); + box-shadow: inset 2px 0 var(--gold); +} + +.post-nav-group button span { + max-width: 100%; + overflow: hidden; + font-size: 0.8rem; + font-weight: 680; + text-overflow: ellipsis; + white-space: nowrap; +} + +.post-nav-group button small { + color: var(--text-faint); + font-size: 0.63rem; +} + +.post-create-item > summary { + padding: 0.45rem 0.6rem; + color: var(--accent-text); + font-size: 0.72rem; +} + +.post-create-item form { + display: grid; + gap: 0.45rem; + padding: 0.55rem; +} + +.post-editor { + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--material-inset) 72%, var(--bg)); +} + +.post-editor-header { + padding-inline: clamp(1rem, 3vw, 2.2rem); +} + +.post-editor-header h2 { + font-size: 1.2rem; +} + +.post-editor-actions, +.post-section-toolbar, +.post-editor-section > header, +.post-writing-section > header, +.post-thumbnail-section > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.post-editor-actions > span { + color: var(--text-muted); + font-size: 0.72rem; +} + +.post-editor-scroll { + display: grid; + flex: 1; + align-content: start; + gap: 1rem; + width: 100%; + max-width: 70rem; + margin-inline: auto; + padding: clamp(1rem, 3vw, 2.2rem); +} + +.post-title-field > span { + display: flex; + justify-content: space-between; +} + +.post-title-field > div, +.copy-field { + display: flex; + align-items: flex-start; + gap: 0.4rem; +} + +.post-title-field input { + font-size: 1.15rem; + font-weight: 720; +} + +.post-title-field input, +.copy-field > :first-child { + min-width: 0; + flex: 1; +} + +.post-title-field button, +.copy-field button { + display: grid; + width: 2.55rem; + height: 2.55rem; + flex: none; + place-items: center; + padding: 0; +} + +.post-writing-section, +.post-editor-section { + display: grid; + gap: 0.7rem; + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line)); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--panel-strong) 96%, transparent); +} + +.post-writing-section h3, +.post-editor-section h3, +.post-section-toolbar h3, +.post-thumbnail-section h3 { + margin: 0; +} + +.post-writing-section textarea { + min-height: 24rem; + font-size: 0.95rem; + line-height: 1.65; +} + +.post-writing-section.exact-source { + border-color: color-mix(in srgb, var(--accent) 28%, var(--line)); +} + +.post-writing-section.exact-source textarea { + background: color-mix(in srgb, var(--field) 94%, var(--accent-soft)); +} + +.post-field-grid, +.post-thumbnail-upload { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.post-thumbnail-upload { + align-items: end; +} + +.post-thumbnail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.7rem; +} + +.post-thumbnail-grid figure { + margin: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--material-inset); +} + +.post-thumbnail-grid figure.selected { + border-color: var(--gold); + box-shadow: 0 0 0 1px var(--gold); +} + +.post-thumbnail-grid img { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; +} + +.post-thumbnail-grid figcaption { + display: grid; + gap: 0.4rem; + padding: 0.6rem; +} + +.post-thumbnail-grid figcaption > span, +.post-thumbnail-grid figcaption > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; +} + +.post-thumbnail-grid figcaption > span { + min-width: 0; + font-size: 0.72rem; +} + +.post-thumbnail-grid figcaption b { + color: var(--gold-strong); + font-size: 0.62rem; +} + +.post-thumbnail-grid figcaption form { + display: inline; +} + +.post-editor-tabs { + position: relative; + z-index: 2; + display: flex; + gap: 0.25rem; + overflow-x: auto; + border-bottom: 1px solid var(--line); +} + +.post-editor-tabs button { + position: relative; + z-index: 3; + flex: none; + padding: 0.65rem 0.8rem; + border: 0; + border-radius: 0; + background: transparent; +} + +.post-editor-tabs button.active { + box-shadow: inset 0 -2px var(--gold); + color: var(--gold-strong); +} + +.post-tab-panel { + display: grid; + gap: 1rem; +} + +.post-tab-panel[hidden] { + display: none; +} + +.monospace { + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.post-empty-editor { + display: grid; + max-width: 30rem; + margin: auto; + justify-items: center; + padding: 2rem; + text-align: center; +} + +.post-empty-editor h2 { + margin-bottom: 0; +} + +.post-empty-editor p { + color: var(--text-muted); +} + +.post-inspector-scroll { + display: grid; + align-content: start; + gap: 0; +} + +.post-inspector-scroll > section { + display: grid; + gap: 0.55rem; + padding: 0.85rem; + border-bottom: 1px solid var(--line); +} + +.post-inspector-scroll label:not(.inline-check) { + display: grid; + gap: 0.3rem; +} + +.post-inspector-scroll h3 { + margin: 0; + font-size: 0.78rem; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.post-inspector-scroll p { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.6rem; + margin: 0; + font-size: 0.72rem; +} + +.post-inspector-scroll p span { + color: var(--text-muted); + text-align: right; +} + +.post-timecode-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; +} + +.post-inspector .post-ai-note { + background: color-mix(in srgb, var(--accent-soft) 45%, transparent); +} + +.post-inspector .post-ai-note p { + display: block; + color: var(--text-muted); + line-height: 1.5; +} + +.post-inspector > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: auto; + padding: 0.7rem 0.8rem; + border-top: 1px solid var(--line); + background: var(--panel-strong); +} + +.post-mobile-switch { + display: none; +} + +.post-material-dialog { + width: min(56rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); + padding: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 36%, var(--line)); + border-radius: var(--radius-large); + background: var(--panel-strong); + box-shadow: var(--shadow-large); +} + +.post-material-dialog > header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--glass-chrome); +} + +.post-material-dialog h2, +.post-material-content h3 { + margin: 0; +} + +.post-material-content { + display: grid; + gap: 1rem; + max-height: calc(100dvh - 8rem); + padding: 1rem; + overflow-y: auto; +} + +.post-material-content > section { + display: grid; + gap: 0.7rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--line); +} + +.post-material-list { + display: grid; + gap: 0.35rem; +} + +.post-material-list article { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--material-inset); +} + +.post-recording-part { + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--material-inset); +} + +.post-recording-part > summary span { + display: grid; +} + +.post-recording-part > form { + margin-top: 0.65rem; +} + +.post-material-list article > div { + display: grid; + min-width: 0; +} + +.post-material-list small { + color: var(--text-muted); +} + +.post-material-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: end; + gap: 0.65rem; + padding: 0.75rem; + border-radius: var(--radius-medium); + background: var(--material-inset); +} + +@media (max-width: 72rem) { + .post-overview-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .post-show-row { + align-items: flex-start; + } + + .post-show-counts { + flex-wrap: wrap; + } + + .post-workspace, + .post-workspace.left-collapsed, + .post-workspace.right-collapsed, + .post-workspace.left-collapsed.right-collapsed { + display: block; + height: auto; + min-height: calc(100dvh - 4.25rem); + overflow: visible; + } + + .post-mobile-switch { + position: sticky; + top: 4.25rem; + z-index: 12; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.2rem; + padding: 0.45rem; + border-bottom: 1px solid var(--line); + background: var(--glass-chrome); + backdrop-filter: blur(20px); + } + + .post-mobile-switch button.active { + border-color: var(--gold); + background: color-mix(in srgb, var(--gold) 12%, var(--panel)); + } + + .post-navigation, + .post-inspector { + display: none; + min-height: calc(100dvh - 8rem); + border: 0; + } + + .post-workspace.show-navigation .post-navigation, + .post-workspace.show-details .post-inspector { + display: flex; + } + + .post-workspace.show-navigation .post-editor, + .post-workspace.show-details .post-editor { + display: none; + } + + .post-workspace.show-navigation .post-navigation > header > button, + .post-workspace.show-details .post-inspector > header > button { + display: none; + } + + .post-navigation-body, + .post-inspector-scroll { + overflow: visible; + } + + .post-editor { + min-height: calc(100dvh - 8rem); + } + + .post-editor-scroll { + overflow: visible; + } +} + +@media (max-width: 52rem) { + .post-overview-filters, + .post-field-grid, + .post-thumbnail-upload, + .post-material-form { + grid-template-columns: minmax(0, 1fr); + } + + .post-show-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + } + + .post-show-counts { + grid-column: 1 / -1; + justify-content: space-between; + order: 3; + width: 100%; + padding-top: 0.5rem; + border-top: 1px solid var(--line); + } + + .post-show-counts > div { + min-width: 2.5rem; + } + + .post-workspace, + .post-workspace.left-collapsed, + .post-workspace.right-collapsed, + .post-workspace.left-collapsed.right-collapsed { + min-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav)); + } + + .post-mobile-switch { + top: calc(var(--mobile-header) + env(safe-area-inset-top)); + } + + .post-editor-header { + align-items: flex-start; + padding: 0.65rem 0.8rem; + } + + .post-editor-header h2 { + font-size: 1rem; + } + + .post-editor-actions { + flex-direction: column-reverse; + align-items: flex-end; + } + + .post-editor-scroll { + padding: 0.8rem; + } + + .post-writing-section, + .post-editor-section { + padding: 0.8rem; + } + + .post-writing-section textarea { + min-height: 18rem; + } + + .post-editor-tabs { + margin-inline: -0.8rem; + padding-inline: 0.8rem; + } + + .post-inspector > footer { + position: static; + } + + .post-material-dialog { + width: 100vw; + height: 100dvh; + max-height: none; + border: 0; + border-radius: 0; + } + + .post-material-content { + max-height: calc(100dvh - 4.5rem); + padding-bottom: calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 1rem); + } +} + .completion-dialog { width: min(38rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); @@ -6067,21 +7170,6 @@ 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); @@ -7176,6 +8264,9 @@ textarea:focus { .mobile-navigation:has(a:nth-child(5)) { grid-template-columns: repeat(5, 1fr); } + .mobile-navigation:has(a:nth-child(6)) { + grid-template-columns: repeat(6, 1fr); + } .mobile-navigation a { display: flex; min-width: 0; @@ -8001,7 +9092,8 @@ textarea:focus { .search-result-columns { grid-template-columns: 1fr; } - .mobile-header .brand > span:last-child { + .mobile-header .brand-name, + .mobile-header .brand-version { display: none; } .mobile-global-search input { @@ -9313,9 +10405,9 @@ button.catalog-entry-main:hover { } .rss-source-filters button.active { - border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); - background: color-mix(in srgb, var(--accent) 14%, var(--panel)); - color: var(--accent-text); + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel)); + color: var(--text); } .rss-result-summary, @@ -9356,13 +10448,13 @@ button.catalog-entry-main:hover { } .rss-entry.selected { - border-color: color-mix(in srgb, var(--accent) 72%, var(--line)); - background: color-mix(in srgb, var(--accent) 15%, var(--panel)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel)); + box-shadow: none; } [data-theme='light'] .rss-entry.selected { - background: color-mix(in srgb, var(--accent) 20%, #fff); + background: color-mix(in srgb, var(--gold) 9%, #fff); } .rss-entry:focus-visible { @@ -9382,9 +10474,9 @@ button.catalog-entry-main:hover { } .rss-entry.selected .rss-entry-state { - border-color: var(--accent); - background: var(--accent); - color: #fff; + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 15%, var(--panel)); + color: var(--gold-strong); } .rss-entry-copy, @@ -9562,8 +10654,8 @@ button.catalog-entry-main:hover { /* Keep the 0.8.1 workspace overrides after the legacy component rules. */ .fundus-editor-surface .piece-script-panel > .piece-writing-stack { grid-row: 1 / span 2; - width: min(100%, 48rem); - justify-self: center; + width: 100%; + justify-self: stretch; } .planning-workspace { @@ -9588,3 +10680,3088 @@ button.catalog-entry-main:hover { padding-inline: 0.9rem; } } + +/* 0.8.2 piece writing room */ +.fundus-editor-surface .piece-dialog-header { + padding: 0.85rem clamp(1rem, 2vw, 1.5rem) 0; +} + +.fundus-editor-surface .piece-dialog-heading { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + column-gap: 1.25rem; +} + +.fundus-editor-surface .piece-editor-kicker, +.fundus-editor-surface .piece-dialog-title, +.fundus-editor-surface .piece-editor-facts { + grid-column: 1; +} + +.piece-editor-facts { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.4rem 0 0.7rem; +} + +.piece-editor-facts span { + padding: 0.2rem 0.48rem; + border: 1px solid color-mix(in srgb, var(--gold) 22%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, var(--gold) 7%, transparent); + color: var(--text-muted); + font-size: 0.64rem; + font-weight: 680; +} + +.piece-editor-view-switch { + display: flex; + grid-column: 2; + grid-row: 1 / span 3; + align-self: end; + gap: 0.2rem; +} + +.piece-editor-view-switch button { + min-height: 2.35rem; + padding: 0.45rem 0.7rem; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 740; + cursor: pointer; +} + +.piece-editor-view-switch button.active { + border-bottom-color: var(--gold); + color: var(--text); +} + +.piece-editor-view-switch .piece-context-switch { + display: none; +} + +.fundus-editor-surface .piece-dialog-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(19rem, 23vw, 22rem); + 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-writing-stack, +.fundus-editor-surface .piece-research-panel { + grid-column: 1; + grid-row: 1 / span 4; + width: 100%; + margin: 0; + align-self: start; + align-content: start; + justify-self: stretch; +} + +.fundus-editor-surface .piece-writing-stack[hidden] { + display: none; +} + +.fundus-editor-surface .piece-research-panel { + display: grid; + gap: 1rem; +} + +.fundus-editor-surface .piece-research-panel[hidden] { + display: none; +} + +.fundus-editor-surface .piece-research-panel > .piece-panel-heading { + margin-bottom: 0; +} + +.fundus-editor-surface .piece-meta-details, +.fundus-editor-surface .piece-presentation-panel, +.fundus-editor-surface .piece-media-panel { + grid-column: 2; + width: 100%; + margin: 0; +} + +.fundus-editor-surface .piece-meta-details { + grid-row: 1; +} + +.fundus-editor-surface .piece-presentation-panel { + grid-row: 2; +} + +.fundus-editor-surface .piece-media-panel { + grid-row: 3; +} + +.piece-writing-document { + gap: 1rem; +} + +.piece-writing-document .rich-markdown-field, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field { + padding: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line)); + border-radius: 1rem; + background: color-mix(in srgb, var(--panel-strong) 90%, var(--gold) 2%); + box-shadow: 0 0.45rem 1.4rem rgb(18 35 58 / 5%); +} + +.piece-writing-document .markdown-editor-heading, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field .markdown-editor-heading { + min-height: 2.85rem; + margin: 0; + padding: 0.55rem 0.7rem 0.5rem 0.9rem; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--material-inset) 54%, transparent); +} + +.piece-writing-document .markdown-editor-heading > span:first-child, +.fundus-editor-surface + .piece-research-panel + > .rich-markdown-field + .markdown-editor-heading + > span:first-child { + font-size: 0.82rem; + letter-spacing: -0.01em; +} + +.piece-writing-document .rich-markdown-surface, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field .rich-markdown-surface, +.piece-writing-document .markdown-editor-field textarea, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field textarea { + min-height: 4.75rem; + padding: 1rem; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + font-size: 0.94rem; + line-height: 1.7; +} + +.piece-writing-document .rich-markdown-field:nth-child(2) .rich-markdown-surface { + min-height: 7rem; +} + +.fundus-editor-surface .piece-writing-stack .rich-markdown-content, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field .rich-markdown-content { + max-width: 78ch; +} + +.fundus-editor-surface .piece-meta-details, +.fundus-editor-surface .piece-presentation-panel, +.fundus-editor-surface .piece-media-panel { + padding: 0.85rem; + border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line)); + border-radius: 1rem; + background: color-mix(in srgb, var(--panel-strong) 82%, var(--material-inset)); + box-shadow: 0 0.45rem 1.4rem rgb(18 35 58 / 5%); +} + +.fundus-editor-surface .piece-meta-details summary { + min-height: 1.8rem; +} + +.fundus-editor-surface .piece-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + margin-top: 0.65rem; +} + +.fundus-editor-surface .piece-meta-grid .wide-field, +.fundus-editor-surface .piece-meta-grid fieldset { + grid-column: 1 / -1; +} + +.fundus-editor-surface .piece-meta-grid textarea { + min-height: 3.7rem; +} + +@media (min-width: 52.01rem) and (max-width: 70rem) { + .fundus-editor-surface .piece-dialog-workspace { + grid-template-columns: minmax(0, 1fr) minmax(17rem, 19rem); + gap: 0.75rem; + padding: 0.75rem; + } +} + +.fundus-editor-surface .piece-presentation-panel > .piece-panel-heading { + align-items: center; + margin-bottom: 0.75rem; +} + +.fundus-editor-surface .piece-presentation-panel > .piece-panel-heading .secondary-button { + padding: 0.4rem 0.55rem; + font-size: 0.62rem; +} + +.fundus-editor-surface .piece-presentation-layout { + grid-template-columns: 1fr; +} + +.fundus-editor-surface .piece-presentation-controls { + padding: 0; + border: 0; + background: transparent; + color: var(--text); +} + +.fundus-editor-surface .piece-presentation-controls :is(.field-label, label) { + color: var(--text-muted); +} + +.fundus-editor-surface .presentation-mode-picker { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.35rem; +} + +.fundus-editor-surface .presentation-mode-picker label { + justify-content: center; + padding: 0.48rem 0.4rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--material-inset); +} + +.fundus-editor-surface .presentation-mode-picker label:has(input:checked) { + border-color: color-mix(in srgb, var(--gold) 60%, var(--line)); + background: color-mix(in srgb, var(--gold) 13%, var(--material-inset)); + color: var(--text); +} + +.piece-inline-presentation-preview { + display: grid; + gap: 0.8rem; + margin-top: 0.2rem; + padding: 0.9rem; + border: 1px solid color-mix(in srgb, var(--gold) 34%, #30435c); + border-radius: 0.85rem; + background: #0b1220; + color: #fff7e8; +} + +.piece-inline-presentation-preview .output-kicker { + margin: 0; + color: #d6ad58; + font-size: 0.6rem; +} + +.fundus-editor-surface .piece-inline-presentation-preview label, +.fundus-editor-surface .piece-inline-presentation-preview .markdown-editor-heading { + color: #b8c3d2; +} + +.fundus-editor-surface + .piece-inline-presentation-preview + :is(input:not([type='radio']), textarea, .rich-markdown-surface) { + border-color: #30435c; + background: #152238; + color: #fff7e8; +} + +.fundus-editor-surface .piece-inline-presentation-preview input[name='outputTitle'] { + font-size: 1.2rem; + font-weight: 760; + letter-spacing: -0.025em; +} + +.fundus-editor-surface + .piece-presentation-panel:has(input[value='text']:checked) + .piece-presentation-preview { + display: none; +} + +.fundus-editor-surface .piece-presentation-preview > .output-content { + min-height: 13rem; +} + +@media (max-width: 52rem) { + .fundus-editor-surface .piece-dialog-header { + padding: 0.75rem 0.75rem 0; + } + + .fundus-editor-surface .piece-dialog-heading { + display: block; + } + + .fundus-editor-surface .piece-dialog-title textarea { + font-size: 1.25rem; + } + + .piece-editor-view-switch { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-right: -2.8rem; + } + + .piece-editor-view-switch .piece-context-switch { + display: block; + } + + .piece-editor-view-switch button { + min-width: 0; + padding-inline: 0.3rem; + font-size: 0.64rem; + } + + .fundus-editor-surface .piece-dialog-workspace { + display: block; + padding: 0.75rem; + overflow: visible; + } + + .fundus-editor-surface .piece-dialog-form, + .fundus-editor-surface .piece-script-panel { + display: block; + } + + .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-writing-stack, + .fundus-editor-surface .piece-research-panel, + .fundus-editor-surface .piece-meta-details, + .fundus-editor-surface .piece-presentation-panel, + .fundus-editor-surface .piece-media-panel { + width: 100%; + margin: 0; + } + + .fundus-editor-surface .piece-context-panel.mobile-context-hidden { + display: none; + } + + .fundus-editor-surface .piece-presentation-panel, + .fundus-editor-surface .piece-media-panel { + margin-top: 0.75rem; + } + + .piece-writing-document .markdown-editor-heading, + .fundus-editor-surface .piece-research-panel > .rich-markdown-field .markdown-editor-heading { + align-items: flex-start; + flex-direction: column; + } + + .markdown-toolbar { + width: 100%; + } + + .markdown-toolbar button { + min-height: 2.35rem; + min-width: 2.35rem; + } + + .fundus-editor-surface .piece-dialog-footer { + align-items: stretch; + padding: 0.65rem 0.75rem; + } + + .fundus-editor-surface .piece-dialog-secondary-actions { + display: flex; + width: 100%; + max-width: none; + flex-wrap: wrap; + justify-content: flex-start; + row-gap: 0.35rem; + overflow: visible; + } + + .fundus-editor-surface .piece-dialog-primary-action { + width: 100%; + justify-content: flex-end; + } +} + +/* 0.8.2 editorial document correction */ +.fundus-navigation.editor-context { + display: flex; + flex-direction: column; +} + +.fundus-navigation.editor-context header { + flex: none; +} + +.fundus-navigation .editor-navigation-close { + display: none; +} + +.editor-overview-link { + display: flex; + min-height: 2.5rem; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.65rem; + padding: 0.5rem 0.55rem; + border-radius: 0.65rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 720; +} + +.editor-overview-link:hover { + background: var(--material-inset); + color: var(--text); +} + +.fundus-navigation.editor-context.collapsed .editor-overview-link { + justify-content: center; + padding-inline: 0; +} + +.fundus-navigation.editor-context.collapsed .editor-overview-link span { + display: none; +} + +.editor-piece-filters { + position: relative; + margin: -0.25rem 0 0.65rem; +} + +.editor-piece-filters > summary { + display: flex; + min-height: 2.25rem; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.55rem; + border-radius: 0.6rem; + color: var(--text-muted); + cursor: pointer; + font-size: 0.7rem; + font-weight: 720; +} + +.editor-piece-filters > summary:hover, +.editor-piece-filters[open] > summary { + background: var(--material-inset); + color: var(--text); +} + +.editor-piece-filters > form { + position: absolute; + z-index: 12; + top: calc(100% + 0.35rem); + left: 0; + display: grid; + width: min(21rem, calc(100vw - 2rem)); + gap: 0.55rem; + padding: 0.75rem; + border: 1px solid var(--line-strong); + border-radius: 0.85rem; + background: var(--glass-strong); + box-shadow: var(--shadow); +} + +.editor-piece-filters label { + display: grid; + gap: 0.25rem; + color: var(--text-muted); + font-size: 0.66rem; + font-weight: 700; +} + +.editor-piece-filters select { + width: 100%; + min-width: 0; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--field); + color: var(--text); +} + +.editor-piece-filters .editor-filter-check { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.fundus-navigation .editor-piece-list { + display: grid; + min-height: 0; + gap: 0.2rem; + overflow-y: auto; +} + +.fundus-navigation .editor-piece-list a { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.55rem; + padding: 0.55rem; + border: 1px solid transparent; + border-radius: 0.7rem; +} + +.fundus-navigation .editor-piece-list a > span { + display: grid; + min-width: 0; + gap: 0.18rem; +} + +.fundus-navigation .editor-piece-list strong, +.fundus-navigation .editor-piece-list small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fundus-navigation .editor-piece-list strong { + font-size: 0.72rem; +} + +.fundus-navigation .editor-piece-list small { + color: var(--text-faint); + font-size: 0.6rem; +} + +.fundus-navigation .editor-piece-list a.active { + border-color: color-mix(in srgb, var(--gold) 42%, var(--line)); + background: color-mix(in srgb, var(--gold) 10%, var(--material-inset)); +} + +.fundus-navigation.editor-context.collapsed .editor-piece-list a { + grid-template-columns: 1fr; + place-items: center; +} + +.fundus-navigation.editor-context.collapsed .editor-piece-list a > span { + display: none; +} + +.fundus-editor-surface { + grid-template-rows: auto minmax(0, 1fr); +} + +.fundus-editor-surface .piece-dialog-header { + align-items: center; + padding: 0.75rem 1rem 0; +} + +.fundus-editor-surface .piece-dialog-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + align-items: center; + gap: 0 1rem; +} + +.piece-editor-title-row { + display: flex; + min-width: 0; + grid-column: 1; + grid-row: 1; + align-items: center; + gap: 0.55rem; +} + +.piece-editor-title-row .inline-title-editor { + min-width: 0; +} + +.piece-editor-title-row .inline-title-trigger { + font-size: clamp(1.35rem, 2.4vw, 1.85rem); +} + +.piece-editor-header-actions { + position: relative; + display: flex; + grid-column: 2; + grid-row: 1; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; +} + +.piece-editor-header-actions .save-indicator { + max-width: 12rem; + overflow: hidden; + color: var(--text-faint); + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-save-button, +.piece-action-menu > summary { + min-height: 2.15rem; + padding: 0.4rem 0.65rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: transparent; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 720; + cursor: pointer; +} + +.compact-save-button:hover, +.piece-action-menu > summary:hover, +.piece-action-menu[open] > summary { + border-color: color-mix(in srgb, var(--gold) 45%, var(--line)); + color: var(--text); +} + +.piece-action-menu { + position: relative; +} + +.piece-action-menu > div { + position: absolute; + z-index: 20; + top: calc(100% + 0.45rem); + right: 0; + display: grid; + width: min(19rem, calc(100vw - 2rem)); + gap: 0.2rem; + padding: 0.55rem; + border: 1px solid var(--line-strong); + border-radius: 0.85rem; + background: var(--glass-strong); + box-shadow: var(--shadow); +} + +.piece-action-menu > div > form, +.piece-action-menu > div > form button, +.piece-action-menu .template-save-menu { + width: 100%; +} + +.piece-action-menu > div > form button, +.piece-action-menu .template-save-menu > summary { + display: flex; + min-height: 2.35rem; + align-items: center; + padding: 0.45rem 0.55rem; +} + +.piece-action-menu .template-save-menu > form { + position: static; + width: 100%; + margin-top: 0.35rem; + box-shadow: none; +} + +.fundus-editor-surface .piece-editor-view-switch { + grid-column: 1 / -1; + grid-row: 2; + justify-self: start; + margin-top: 0.35rem; +} + +.piece-editor-view-switch .piece-list-switch { + display: none; +} + +.fundus-editor-surface .piece-dialog-workspace { + gap: 0.8rem; + padding: 0.8rem; +} + +.piece-writing-document { + gap: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line)); + border-radius: 0.95rem; + background: color-mix(in srgb, var(--panel-strong) 74%, transparent); + box-shadow: 0 0.65rem 1.8rem rgb(18 35 58 / 4%); +} + +.piece-writing-document .rich-markdown-field, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field { + padding: 0; + overflow: visible; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 72%, transparent); + border-radius: 0; + background: transparent; + box-shadow: none; + transition: + background-color 120ms ease, + box-shadow 120ms ease; +} + +.piece-writing-document .rich-markdown-field:last-child { + border-bottom: 0; +} + +.piece-writing-document .rich-markdown-field:focus-within, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field:focus-within { + background: color-mix(in srgb, var(--gold) 5%, var(--field)); + box-shadow: inset 3px 0 color-mix(in srgb, var(--gold) 70%, var(--accent)); +} + +.piece-writing-document .markdown-editor-heading, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field .markdown-editor-heading { + min-height: 0; + margin: 0; + padding: 0.85rem 1rem 0.25rem; + border: 0; + background: transparent; +} + +.fundus-editor-surface .rich-markdown-field .markdown-toolbar, +.piece-writing-document .rich-markdown-field .markdown-toolbar { + display: none; +} + +.fundus-editor-surface .rich-markdown-field:focus-within .markdown-toolbar, +.piece-writing-document .rich-markdown-field:focus-within .markdown-toolbar { + display: flex; +} + +.markdown-toolbar-toggle { + display: none; + align-items: center; + gap: 0.3rem; + border: 0; + background: transparent; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.piece-writing-document .rich-markdown-surface, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field .rich-markdown-surface, +.piece-writing-document .markdown-editor-field textarea, +.fundus-editor-surface .piece-research-panel > .rich-markdown-field textarea { + min-height: 3.4rem; + padding: 0.45rem 1rem 0.95rem; + border: 0; + background: transparent; + box-shadow: none; + font-size: 0.95rem; + line-height: 1.68; +} + +.piece-writing-document .rich-markdown-field:nth-child(2) .rich-markdown-surface { + min-height: 4.6rem; +} + +.fundus-editor-surface .piece-meta-details, +.fundus-editor-surface .piece-presentation-panel, +.fundus-editor-surface .piece-media-panel { + padding: 0.75rem; + border-color: color-mix(in srgb, var(--gold) 14%, var(--line)); + background: color-mix(in srgb, var(--panel-strong) 68%, transparent); + box-shadow: none; +} + +.piece-context-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.6rem; +} + +.piece-context-heading h2 { + margin: 0; + font-size: 0.9rem; +} + +.piece-context-heading small { + color: var(--text-faint); + font-size: 0.58rem; + text-transform: uppercase; +} + +.fundus-editor-surface .piece-meta-grid { + gap: 0.45rem; + margin: 0; +} + +.fundus-editor-surface .piece-meta-grid fieldset { + margin-top: 0.15rem; +} + +.fundus-editor-surface .piece-meta-grid :is(input:not([type='checkbox']), select) { + padding: 0.45rem 0.5rem; + background: color-mix(in srgb, var(--field) 70%, transparent); +} + +.piece-more-details { + margin-top: 0.55rem; + border-top: 1px solid var(--line); +} + +.piece-more-details > summary { + min-height: 2.3rem; + padding: 0.6rem 0.1rem 0; + color: var(--text-muted); + cursor: pointer; + font-size: 0.68rem; + font-weight: 720; +} + +.piece-more-details > div { + display: grid; + gap: 0.55rem; + padding-top: 0.55rem; +} + +.piece-more-details label, +.piece-more-details fieldset { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0; + border: 0; + color: var(--text-muted); + font-size: 0.66rem; + font-weight: 700; +} + +.piece-more-details :is(select, textarea) { + width: 100%; + min-width: 0; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--field); + color: var(--text); +} + +.fundus-editor-surface .presentation-mode-picker { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.25rem; +} + +.fundus-editor-surface .presentation-mode-picker label { + min-width: 0; + padding: 0.42rem 0.2rem; + font-size: 0.59rem; +} + +.fundus-editor-surface .piece-presentation-panel > .piece-panel-heading { + margin-bottom: 0.55rem; +} + +.piece-inline-presentation-preview { + margin-top: 0.1rem; + padding: 0.75rem; +} + +@media (max-width: 52rem) { + .fundus-editor-workspace:not(.mobile-piece-list-open) .fundus-navigation.editor-context { + display: none; + } + + .fundus-editor-workspace.mobile-piece-list-open .fundus-navigation.editor-context { + position: fixed; + z-index: 40; + inset: calc(var(--mobile-header) + env(safe-area-inset-top) + 0.5rem) 0.65rem + calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 0.5rem); + display: flex; + height: auto; + padding: 0.75rem; + overflow: hidden; + background: color-mix(in srgb, var(--panel-strong) 96%, var(--bg)); + box-shadow: var(--shadow-large); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + .fundus-navigation.editor-context .editor-navigation-close { + display: grid; + } + + .fundus-navigation.editor-context header { + display: flex; + } + + .fundus-navigation.editor-context header > button:not(.editor-navigation-close) { + display: none; + } + + .fundus-navigation.editor-context header > div { + display: block; + } + + .fundus-editor-surface .piece-dialog-header { + align-items: flex-start; + padding: 0.65rem 0.7rem 0; + } + + .fundus-editor-surface .piece-dialog-heading { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.35rem; + } + + .piece-editor-title-row, + .piece-editor-header-actions, + .fundus-editor-surface .piece-editor-view-switch { + grid-column: 1; + } + + .piece-editor-header-actions { + grid-row: 2; + justify-content: flex-start; + } + + .piece-editor-header-actions .save-indicator { + max-width: 8rem; + } + + .fundus-editor-surface .piece-editor-view-switch { + grid-row: 3; + display: grid; + width: calc(100% + 2.5rem); + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0.1rem -2.5rem 0 0; + } + + .piece-editor-view-switch .piece-list-switch, + .piece-editor-view-switch .piece-context-switch { + display: block; + } + + .piece-editor-view-switch button { + min-height: 2.75rem; + padding: 0.35rem 0.15rem; + font-size: 0.6rem; + } + + .fundus-editor-surface .markdown-toolbar-toggle, + .piece-writing-document .markdown-toolbar-toggle { + display: inline-flex; + min-height: 2.35rem; + margin-left: auto; + cursor: pointer; + } + + .piece-writing-document .markdown-editor-heading, + .fundus-editor-surface .piece-research-panel > .rich-markdown-field .markdown-editor-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + padding-top: 0.6rem; + } + + .fundus-editor-surface .rich-markdown-field .markdown-toolbar { + grid-column: 1 / -1; + width: 100%; + } + + .fundus-editor-surface .presentation-mode-picker { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .piece-action-menu > div { + right: auto; + left: 0; + } +} + +/* 0.8.2 intermediate-width navigation correction */ +.brand-version { + padding: 0.18rem 0.38rem; + border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line)); + border-radius: 999px; + color: var(--text-faint); + font-size: 0.58rem; + font-weight: 720; + letter-spacing: 0.02em; +} + +.fundus-navigation :is(nav a, header button, .editor-overview-link) > .app-icon { + width: 1.1rem; + height: 1.1rem; +} + +@media (max-width: 78rem) { + .fundus-workspace:has(.fundus-navigation.collapsed) { + grid-template-columns: 4.1rem minmax(0, 1fr); + } +} + +@media (min-width: 52.01rem) and (max-width: 70rem) { + .app-header { + gap: 0.65rem; + padding-inline: 0.75rem; + } + + .app-header .main-navigation { + gap: 0.18rem; + } + + .app-header .main-navigation a { + min-width: 2.75rem; + justify-content: center; + padding-inline: 0.6rem; + } + + .app-header .main-navigation a > span { + display: none; + } + + .global-search-form label { + min-width: clamp(10rem, 23vw, 13rem); + } +} + +@media (max-width: 52rem) { + .fundus-workspace:has(.fundus-navigation.collapsed) { + grid-template-columns: minmax(0, 1fr); + } +} + +/* 0.8.2 editorial workspace alignment */ +.fundus-editor-surface .piece-dialog-heading { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto auto; + gap: 0.4rem; +} + +.fundus-editor-surface .piece-dialog-header { + position: relative; + z-index: 12; +} + +.piece-editor-title-row { + grid-column: 1; + grid-row: 1; +} + +.piece-editor-header-actions { + grid-column: 1; + grid-row: 2; + justify-content: flex-start; +} + +.fundus-editor-surface .piece-editor-view-switch { + grid-column: 1; + grid-row: 3; + margin-top: 0; +} + +.fundus-navigation .editor-piece-list { + padding-right: 0.3rem; + scrollbar-gutter: stable; +} + +.fundus-navigation.editor-context.collapsed .editor-piece-list { + padding-right: 0; + scrollbar-gutter: auto; +} + +/* Live uses the same quiet document and rundown language as Pieces and Streams. */ +.live-page { + gap: 0.45rem; +} + +.live-command-header, +.live-action-bar { + border-color: color-mix(in srgb, var(--gold) 16%, var(--line)); + background: color-mix(in srgb, var(--panel-strong) 88%, var(--bg)); + box-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.live-command-header { + padding: 0.4rem 0.55rem; +} + +.live-status-row { + border-top-color: color-mix(in srgb, var(--line) 72%, transparent); +} + +.section-countdown, +.total-countdown, +.live-run-button, +.live-menu-trigger, +.timer-reset-menu > summary { + background: color-mix(in srgb, var(--material-inset) 72%, transparent); +} + +.live-workspace { + gap: 0.5rem; +} + +.live-current, +.live-rail { + border-color: color-mix(in srgb, var(--gold) 14%, var(--line)); + background: color-mix(in srgb, var(--panel-strong) 88%, var(--bg)); + box-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.live-current-scroll { + padding: clamp(0.9rem, 2vw, 1.7rem); +} + +.live-piece-heading, +.live-twitch-tags, +.live-script-document, +.live-support-actions { + width: min(100%, 68rem); + margin-inline: auto; +} + +.live-piece-heading { + padding-bottom: 0.8rem; +} + +.live-script-document { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 14%, var(--line)); + border-radius: 0.95rem; + background: color-mix(in srgb, var(--panel-strong) 76%, transparent); +} + +.live-script-section, +.moderation-card { + max-width: none; + margin: 0; + padding: 1rem 1.1rem; + border: 0; + border-top: 1px solid color-mix(in srgb, var(--line) 74%, transparent); + border-radius: 0; + background: transparent; +} + +.live-script-section:first-child { + border-top: 0; +} + +.live-script-section > span, +.live-script-section > .eyebrow { + margin: 0 0 0.45rem; + color: var(--accent); +} + +.live-intro .prompter-copy, +.live-script-section .live-markdown { + max-width: 58ch; +} + +.moderation-card .live-markdown { + font-family: inherit; +} + +.transition-card { + border-top-color: color-mix(in srgb, var(--gold) 28%, var(--line)); +} + +.live-script-document .live-empty-copy { + margin: 0; + padding: 2rem 1.1rem; +} + +.live-support-actions { + margin-top: 0.75rem; + padding-top: 0; + border-top: 0; +} + +.live-rail { + padding: 0.75rem; +} + +.rail-section-items { + gap: 0; + padding-bottom: 0.5rem; +} + +.live-run-item { + border: 0; + border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent); + border-radius: 0; + background: transparent; +} + +.live-run-item.current { + border-color: color-mix(in srgb, var(--gold) 34%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--material-inset)); + box-shadow: inset 3px 0 var(--gold); +} + +.live-run-item.selected:not(.current) { + background: color-mix(in srgb, var(--accent) 7%, transparent); + box-shadow: inset 3px 0 color-mix(in srgb, var(--accent) 62%, transparent); +} + +.live-run-item.presented { + outline: 1px solid color-mix(in srgb, var(--accent) 52%, transparent); + outline-offset: -1px; +} + +.live-run-item:has(.live-run-actions .text-button) { + grid-template-columns: minmax(0, 1fr); +} + +.live-run-item:has(.live-run-actions .text-button) .live-run-actions { + justify-content: space-between; + margin: 0; + padding: 0 0.45rem 0.4rem; +} + +.live-action-bar { + padding: 0.35rem 0.5rem; +} + +@media (max-width: 46rem) { + .live-rail { + background: color-mix(in srgb, var(--panel-strong) 96%, var(--bg)); + } + + .live-script-section, + .moderation-card { + padding: 0.85rem 0.9rem; + } +} + +/* Stream planning keeps quick metadata beside the rundown. */ +.planning-workspace { + grid-template-columns: minmax(0, 1fr) clamp(18rem, 23vw, 22rem); +} + +.planning-workspace.library-open { + grid-template-columns: minmax(15rem, 18rem) minmax(20rem, 1fr) clamp(18rem, 21vw, 21rem); +} + +.planning-workspace.library-open .builder-filter { + grid-template-columns: minmax(0, 1fr); +} + +.planning-workspace .run-pane { + width: 100%; + max-width: none; +} + +.stream-inspector-slot { + min-width: 0; +} + +.stream-inspector { + display: grid; + position: sticky; + top: 4.9rem; + height: calc(100dvh - 5.65rem); + grid-template-rows: auto minmax(0, 1fr) auto auto; + padding: 0; + overflow: hidden; + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); + box-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.stream-inspector-header { + padding: 0.9rem; + border-bottom: 1px solid var(--line); +} + +.stream-inspector-header > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.stream-inspector-header :is(h2, p) { + margin: 0; +} + +.stream-inspector-header h2 { + margin-top: 0.2rem; + font-size: 1.05rem; + letter-spacing: -0.02em; + overflow-wrap: anywhere; +} + +.stream-inspector-header > p:last-child { + margin-top: 0.3rem; + color: var(--text-faint); + font-size: 0.65rem; + line-height: 1.45; +} + +.stream-inspector-form { + display: grid; + min-height: 0; + align-content: start; + gap: 0.75rem; + padding: 0.9rem; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.stream-inspector-form label, +.stream-inspector-commands label { + display: grid; + min-width: 0; + gap: 0.25rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.stream-inspector-form :is(input, select, textarea), +.stream-inspector-commands select { + width: 100%; + min-width: 0; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line-strong); + border-radius: 0.62rem; + background: var(--field); + color: var(--text); + font: inherit; +} + +.stream-inspector-form textarea { + resize: vertical; +} + +.stream-inspector-form small, +.stream-inspector-note { + color: var(--text-faint); + font-size: 0.62rem; +} + +.stream-inspector-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.55rem; +} + +.stream-inspector .copy-field { + grid-template-columns: minmax(0, 1fr); +} + +.stream-inspector .copy-field button { + width: fit-content; + justify-self: end; +} + +.stream-inspector-commands { + padding: 0.65rem 0.9rem; + border-top: 1px solid var(--line); +} + +.stream-inspector-commands > summary { + cursor: pointer; + font-size: 0.68rem; + font-weight: 720; +} + +.stream-inspector-commands > div { + display: grid; + gap: 0.35rem; + margin-top: 0.6rem; +} + +.stream-inspector-commands form { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.stream-inspector-commands form label { + flex: 1; +} + +.stream-inspector-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + padding: 0.7rem 0.9rem; + border-top: 1px solid var(--line); + background: var(--panel-strong); +} + +.stream-inspector-actions.single-action { + justify-content: flex-end; +} + +.stream-inspector-actions .secondary-button, +.stream-inspector-actions .primary-button { + justify-content: center; +} + +.editable-run-section.selected, +.editable-usage.selected, +.reserve-usage.selected { + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 28%, transparent); +} + +.section-drag-handle:focus-visible, +.usage-drag-copy:focus-visible { + outline: 3px solid var(--focus); + outline-offset: -3px; +} + +@media (max-width: 72rem) { + .planning-workspace, + .planning-workspace.library-open { + display: block; + } + + .stream-inspector { + position: static; + height: auto; + min-height: min(42rem, calc(100dvh - 10rem)); + } + + .stream-inspector-form { + overflow: visible; + } +} + +@media (max-width: 52rem) { + .stream-inspector { + min-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 7rem); + } + + .stream-inspector-grid { + grid-template-columns: minmax(0, 1fr); + } + + .stream-inspector-actions { + position: sticky; + z-index: 5; + bottom: calc(var(--mobile-nav) + env(safe-area-inset-bottom)); + } + + .stream-inspector-commands { + margin-bottom: 4.5rem; + } +} + +/* Keep Post tabs above the general navigation reset declared earlier in this stylesheet. */ +.post-editor-tabs { + display: flex; + min-height: 2.75rem; + align-items: stretch; + color: var(--text-muted); +} + +.post-editor-tabs button { + display: block; + min-height: 2.75rem; + color: inherit; + pointer-events: auto; + visibility: visible; + opacity: 1; +} + +.post-editor-tabs button.active { + color: var(--gold-strong); +} + +@media (max-width: 52rem) { + .post-navigation, + .post-editor, + .post-inspector { + padding-bottom: calc(var(--mobile-nav) + env(safe-area-inset-bottom)); + } +} + +/* 0.9.0 shared editorial workbench */ +.unified-workspace, +.fundus-list-workspace.unified-workspace { + display: grid; + width: 100%; + min-height: calc(100dvh - 4.25rem); + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr) clamp(19rem, 23vw, 22rem); + gap: 0.85rem; + padding: 0.85rem; +} + +.unified-workspace:has(.fundus-navigation.collapsed) { + grid-template-columns: 4.1rem minmax(0, 1fr) clamp(19rem, 23vw, 22rem); +} + +.workspace-navigation, +.workspace-content-pane, +.content-quick-inspector { + 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%); +} + +.workspace-navigation, +.content-quick-inspector { + position: sticky; + top: 5.1rem; + display: flex; + height: calc(100dvh - 5.95rem); + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.workspace-navigation > header, +.workspace-content-pane > header, +.content-quick-inspector > header { + display: flex; + min-height: 4.3rem; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + padding: 0.8rem; + border-bottom: 1px solid var(--line); +} + +.workspace-navigation h1, +.workspace-navigation h2, +.workspace-content-pane > header h2, +.content-quick-inspector h2 { + margin: 0; + font-size: 1rem; +} + +.workspace-navigation > header > span, +.workspace-content-pane > header > span { + padding: 0.25rem 0.55rem; + border-radius: 999px; + background: var(--material-deep); + color: var(--text-muted); + font-size: 0.7rem; +} + +.workspace-filter-form, +.workspace-navigation-group, +.workspace-navigation-note, +.workspace-navigation > .capture-panel { + display: grid; + gap: 0.65rem; + padding: 0.8rem; + border-bottom: 1px solid var(--line); +} + +.workspace-navigation > .secondary-button { + margin: 0.8rem; +} + +.workspace-navigation-note { + grid-template-columns: auto 1fr; + align-items: start; + color: var(--text-muted); +} + +.workspace-navigation-note p { + margin: 0; + font-size: 0.75rem; + line-height: 1.45; +} + +.workspace-navigation-group > button { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.6rem; + border-color: transparent; + background: transparent; + text-align: left; +} + +.workspace-navigation-group > button.active { + border-color: color-mix(in srgb, var(--gold) 36%, var(--line)); + background: color-mix(in srgb, var(--gold) 10%, var(--material-inset)); +} + +.workspace-navigation-group > button b { + color: var(--text-faint); + font-size: 0.7rem; +} + +.content-quick-inspector > header > div { + min-width: 0; +} + +.content-quick-inspector > header h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-expand-button { + display: grid; + width: 2.4rem; + height: 2.4rem; + flex: none; + place-items: center; + border: 1px solid color-mix(in srgb, var(--gold) 38%, var(--line)); + border-radius: 0.65rem; + background: color-mix(in srgb, var(--gold) 10%, var(--material-inset)); + color: var(--gold-strong); +} + +.content-quick-inspector-scroll { + display: grid; + min-height: 0; + align-content: start; + gap: 0; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.content-quick-inspector-scroll > form, +.content-quick-inspector-scroll > section, +.quick-fact-list { + display: grid; + gap: 0.65rem; + margin: 0; + padding: 0.85rem; + border-bottom: 1px solid var(--line); +} + +.content-quick-inspector-scroll label { + display: grid; + gap: 0.3rem; +} + +.content-quick-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.quick-upload-section form { + display: grid; + gap: 0.55rem; +} + +.quick-fact-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); +} + +.quick-fact-list dd { + margin: 0; + font-weight: 750; +} + +.content-quick-inspector > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: auto; + padding: 0.75rem; + border-top: 1px solid var(--line); + background: var(--panel-strong); +} + +.content-quick-inspector > footer form { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.content-inspector-empty { + display: grid; + margin: auto; + justify-items: center; + padding: 1.5rem; + color: var(--text-muted); + text-align: center; +} + +.content-inspector-empty .app-icon { + width: 1.7rem; + height: 1.7rem; + color: var(--gold); +} + +.fundus-list-workspace .fundus-list-pane, +.workspace-content-pane { + padding: 1rem; +} + +.post-library-workspace .post-show-row { + width: 100%; + color: inherit; + text-align: left; +} + +.post-library-workspace .post-show-row.active { + border-color: color-mix(in srgb, var(--gold) 52%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel)); + box-shadow: none; +} + +.inbox-catalog { + display: grid; + align-content: start; + gap: 1rem; +} + +.inbox-catalog-group { + display: grid; + gap: 0.35rem; +} + +.inbox-catalog-group h3 { + margin: 0.25rem 0; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.inbox-catalog-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.75rem; + border-color: var(--line); + background: color-mix(in srgb, var(--panel) 92%, transparent); + color: inherit; + text-align: left; +} + +.inbox-catalog-row > span { + display: grid; + gap: 0.15rem; + min-width: 0; +} + +.inbox-catalog-row small { + color: var(--text-faint); +} + +.inbox-catalog-row.active { + border-color: color-mix(in srgb, var(--gold) 48%, var(--line)); + background: color-mix(in srgb, var(--gold) 8%, var(--panel)); + box-shadow: none; +} + +.editor-route-shell { + min-height: calc(100dvh - 4.25rem); +} + +.editor-workspace-dialog { + display: none; + width: min(96rem, calc(100vw - 2rem)); + height: min(58rem, calc(100dvh - 2rem)); + max-width: none; + max-height: none; + padding: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 58%, var(--line-strong)); + border-radius: var(--radius-large); + background: var(--panel-strong); + box-shadow: var(--shadow-large); + color: var(--text); +} + +.editor-workspace-dialog[open] { + display: block; +} + +.editor-workspace-dialog::backdrop { + background: color-mix(in srgb, var(--bg) 54%, transparent); + backdrop-filter: blur(7px) saturate(0.95); +} + +.post-workspace.post-editor-overlay, +.post-workspace.post-editor-overlay.right-collapsed { + height: 100%; + min-height: 0; + grid-template-columns: minmax(0, 1fr) clamp(19rem, 23vw, 22rem); +} + +.post-workspace.post-editor-overlay.right-collapsed { + grid-template-columns: minmax(0, 1fr) 3.5rem; +} + +.post-editor-context-toolbar { + position: relative; + z-index: 5; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.8rem; + border-bottom: 1px solid var(--line); + background: var(--glass-chrome); +} + +.post-editor-context-toolbar > label { + min-width: 12rem; + flex: 1; +} + +.post-create-menu { + position: relative; +} + +.post-create-menu > summary { + list-style: none; +} + +.post-create-menu > div { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + z-index: 20; + display: grid; + width: min(24rem, 80vw); + gap: 0.6rem; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel-strong); + box-shadow: var(--shadow-large); +} + +.post-create-menu form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.5rem; +} + +@media (max-width: 78rem) { + .unified-workspace, + .fundus-list-workspace.unified-workspace, + .unified-workspace:has(.fundus-navigation.collapsed) { + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); + } + + .unified-workspace > .content-quick-inspector { + position: static; + width: 100%; + height: auto; + max-height: 42rem; + grid-column: 2; + } +} + +@media (max-width: 52rem) { + .unified-workspace, + .fundus-list-workspace.unified-workspace, + .unified-workspace:has(.fundus-navigation.collapsed) { + display: block; + min-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav)); + padding: 0.55rem; + } + + .unified-workspace > .fundus-navigation, + .unified-workspace > .workspace-navigation { + position: static; + width: 100%; + height: auto; + margin-bottom: 0.55rem; + } + + .unified-workspace > .content-quick-inspector { + width: 100%; + max-height: none; + margin-top: 0.55rem; + } + + .editor-workspace-dialog { + width: 100vw; + height: 100dvh; + max-height: none; + border: 0; + border-radius: 0; + } + + .post-workspace.post-editor-overlay, + .post-workspace.post-editor-overlay.right-collapsed { + display: block; + height: 100%; + min-height: 0; + } + + .post-editor-overlay .post-mobile-switch { + position: static; + grid-template-columns: repeat(2, 1fr); + } + + .post-editor-context-toolbar { + align-items: stretch; + flex-direction: column; + } + + .post-editor-context-toolbar > label { + min-width: 0; + } +} + +/* Stream-aligned Pieces and Post workbenches. */ +.post-navigation-sort form, +.post-navigation-sort label { + display: block; +} + +.post-navigation-sort select { + width: 100%; + min-width: 0; + padding: 0.5rem 0.55rem; + border: 1px solid var(--line); + border-radius: 0.68rem; + background: var(--field); + color: var(--text); + font: inherit; + font-size: 0.72rem; +} + +.content-quick-inspector.stream-inspector > .stream-inspector-header { + display: block; + min-height: 0; + padding: 0.9rem; +} + +.content-quick-inspector.stream-inspector { + min-height: 0; +} + +.content-quick-inspector.stream-inspector > .stream-inspector-header > div { + display: flex; +} + +.content-quick-inspector.stream-inspector > .stream-inspector-header h2 { + max-width: 100%; + overflow: visible; + text-overflow: clip; + white-space: normal; +} + +.piece-quick-inspector-scroll { + display: block; + overflow-y: auto; +} + +.piece-quick-inspector-scroll > .stream-inspector-form { + overflow: visible; + border-bottom: 1px solid var(--line); +} + +.piece-quick-inspector-scroll > .quick-upload-section { + padding: 0.9rem; +} + +.piece-quick-inspector-scroll > .quick-upload-section h3, +.post-overview-inspector h3 { + margin: 0; + font-size: 0.76rem; +} + +.post-overview-inspector.stream-inspector-form { + gap: 0; + padding: 0; +} + +.post-overview-inspector > section, +.post-overview-inspector > .quick-fact-list { + padding: 0.9rem; +} + +.post-workspace.post-editor-overlay { + gap: 0.75rem; + padding: 0.75rem; + background: var(--material-deep); +} + +.post-editor-overlay > .post-editor, +.post-editor-overlay > .post-inspector { + border: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line)); + border-radius: var(--radius-medium); + box-shadow: inset 0 1px rgb(255 255 255 / 7%); +} + +.post-editor-overlay > .post-editor { + padding: 0; + overflow: hidden; + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); +} + +.post-editor-overlay > .post-inspector.stream-inspector { + position: static; + height: auto; + min-height: 0; + border-left: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line)); +} + +.post-editor-overlay .post-editor-header { + padding: 0.9rem; + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); +} + +.post-editor-overlay .post-editor-context-toolbar { + padding: 0.55rem 0.9rem; + background: var(--panel-strong); +} + +.post-editor-overlay .post-editor-context-toolbar select { + width: 100%; + min-width: 0; + padding: 0.55rem 0.62rem; + border: 1px solid var(--line-strong); + border-radius: 0.62rem; + background: var(--field); + color: var(--text); + font: inherit; +} + +.post-editor-overlay .post-inspector > .stream-inspector-header { + display: block; + min-height: 0; + background: transparent; +} + +.post-editor-overlay .post-inspector-scroll.stream-inspector-form { + display: block; + padding: 0; +} + +.post-editor-overlay .post-inspector-scroll > section { + padding: 0.9rem; +} + +.post-editor-scroll > label, +.post-editor-scroll > .post-field-grid > label, +.post-tab-panel > label, +.post-tab-panel > .post-field-grid > label, +.post-writing-section > label, +.post-editor-section > label, +.post-thumbnail-upload > label { + display: grid; + gap: 0.3rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.post-editor-scroll :is(input, select, textarea) { + border-color: var(--line-strong); + background: var(--field); +} + +@media (max-width: 72rem) { + .post-workspace.post-editor-overlay { + padding: 0; + } + + .post-editor-overlay > .post-editor, + .post-editor-overlay > .post-inspector { + border: 0; + border-radius: 0; + } +} + +/* Final cascade for the shared selection and three-pane Post workbench. */ +.post-workspace.post-editor-overlay { + grid-template-columns: minmax(14rem, 17rem) minmax(0, 1fr) clamp(19rem, 23vw, 22rem); +} + +.post-editor-overlay > .post-navigation { + padding: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 12%, var(--line)); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--panel-strong) 96%, transparent); +} + +.post-editor-overlay .post-editor-scroll :is(input, select, textarea), +.post-editor-overlay .post-inspector :is(input, select, textarea), +.post-editor-overlay .post-editor-context-toolbar select, +.post-create-menu input { + background: color-mix(in srgb, var(--panel-strong) 97%, var(--gold) 3%); +} + +label:has(> input[type='checkbox']), +label:has(> input[type='radio']) { + position: relative; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: transparent; + cursor: pointer; +} + +label:has(> input[type='checkbox']:checked), +label:has(> input[type='radio']:checked) { + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel-strong)); + box-shadow: none; + color: var(--text); +} + +label > input[type='checkbox'], +label > input[type='radio'] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; + opacity: 0; +} + +label:has(> input[type='checkbox']:focus-visible), +label:has(> input[type='radio']:focus-visible) { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +@media (max-width: 72rem) { + .post-workspace.post-editor-overlay { + display: block; + } + + .post-editor-overlay > .post-navigation { + border: 0; + border-radius: 0; + } +} + +/* Postproduktion follows the regular Streams page instead of an editor overlay. */ +.post-detail-page { + width: 100%; + max-width: 92rem; + padding-top: 1rem; +} + +.post-detail-heading { + margin-bottom: 1rem; +} + +.post-page-workspace { + height: calc(100dvh - 13.5rem); + min-height: 40rem; + gap: 0.85rem; + overflow: hidden; +} + +.post-page-workspace > :is(.post-navigation, .post-editor, .post-inspector) { + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); + box-shadow: var(--shadow-soft); +} + +.post-page-workspace > .post-navigation, +.post-page-workspace > .post-inspector { + border-inline: 1px solid var(--line); +} + +.post-navigation > header { + align-items: flex-start; +} + +.post-navigation-stream { + display: grid; + gap: 0.15rem; + margin: 0.55rem 0 0; + color: var(--text-muted); + font-size: 0.68rem; + line-height: 1.35; +} + +.post-navigation-stream strong { + color: var(--text); + font-size: 0.72rem; +} + +.post-page-workspace .post-inspector > .stream-inspector-header { + display: block; +} + +.post-page-workspace .post-inspector > .stream-inspector-header h2 { + margin-top: 0.2rem; +} + +.post-page-workspace + :is(.post-editor-scroll, .post-inspector) + :is(input:not([type='checkbox']):not([type='radio']):not([type='hidden']), select, textarea), +.post-material-dialog + :is(input:not([type='checkbox']):not([type='radio']):not([type='hidden']), select, textarea) { + width: 100%; + min-width: 0; + min-height: 2.45rem; + padding: 0.48rem 0.58rem; + border: 1px solid var(--line-strong); + border-radius: 0.65rem; + background: var(--field); + color: var(--text); + font: inherit; +} + +.post-page-workspace :is(.post-writing-section, .post-editor-section, .post-overview-group) { + border-color: var(--line); + border-radius: var(--radius-large); + background: var(--panel-strong); +} + +.post-page-workspace .post-nav-group > div > button { + border-radius: var(--radius-small); +} + +.post-page-workspace .post-nav-group > div > button.active { + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel-strong)); + box-shadow: none; +} + +.post-page-workspace .post-material-button { + border-color: color-mix(in srgb, var(--accent) 28%, var(--line)); + border-radius: var(--radius-small); + background: color-mix(in srgb, var(--accent-soft) 52%, var(--panel-strong)); +} + +.post-page-workspace .post-quick-create button { + border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); + border-radius: var(--radius-small); + color: var(--accent-text); +} + +.post-page-workspace .post-editor-tabs button.active { + box-shadow: inset 0 -2px var(--gold); + color: var(--gold-strong); +} + +.post-page-workspace .post-thumbnail-grid figure.selected { + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 28%, transparent); +} + +.post-library-workspace .post-show-row:hover { + border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); + background: color-mix(in srgb, var(--accent-soft) 45%, var(--panel)); +} + +.post-navigation-panel 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); +} + +@media (max-width: 72rem) { + .post-page-workspace { + height: auto; + min-height: calc(100dvh - 12rem); + overflow: visible; + } + + .post-page-workspace > :is(.post-navigation, .post-editor, .post-inspector) { + border-radius: var(--radius-large); + } + + .post-mobile-switch button.active { + border-color: color-mix(in srgb, var(--gold) 38%, var(--line)); + background: color-mix(in srgb, var(--gold) 10%, var(--material-inset)); + } +} + +/* Live is a read-only projection of Streams and uses the same pane language. */ +.live-view-page { + width: 100%; + max-width: 100rem; + padding-top: 1rem; +} + +.live-view-heading { + margin-bottom: 1rem; +} + +.live-readonly-state { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0.7rem; + border: 1px solid color-mix(in srgb, var(--gold) 38%, var(--line)); + border-radius: 0.7rem; + background: color-mix(in srgb, var(--gold) 8%, var(--panel)); + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 700; +} + +.live-view-switch { + display: none; +} + +.live-view-workspace { + display: grid; + height: calc(100dvh - 13.5rem); + min-height: 38rem; + grid-template-columns: minmax(15rem, 18rem) minmax(22rem, 1fr) clamp(18rem, 22vw, 21rem); + gap: 0.85rem; +} + +.live-view-workspace > :is(.live-view-rundown, .live-view-content, .live-view-details) { + min-width: 0; + height: 100%; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); + box-shadow: var(--shadow-soft); +} + +.live-view-rundown { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding: 0; +} + +.live-view-rundown .pane-heading, +.live-view-content-header { + display: flex; + min-height: 4.3rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.8rem 0.9rem; + border-bottom: 1px solid var(--line); +} + +.live-view-rundown .pane-heading :is(p, h2), +.live-view-content-header :is(p, h2) { + margin: 0; +} + +.live-view-rundown .pane-heading h2, +.live-view-content-header h2 { + margin-top: 0.15rem; + font-size: 1.05rem; +} + +.live-view-rundown .pane-heading > span { + padding: 0.25rem 0.55rem; + border-radius: 999px; + background: var(--material-deep); + color: var(--text-muted); + font-size: 0.68rem; +} + +.live-view-rundown-scroll, +.live-view-content-scroll { + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.live-view-rundown-scroll { + padding: 0.75rem; +} + +.live-view-section { + display: grid; + gap: 0.45rem; + padding-bottom: 0.9rem; +} + +.live-view-section + .live-view-section { + padding-top: 0.9rem; + border-top: 1px solid var(--line); +} + +.live-view-section > header { + display: grid; + grid-template-columns: 1.7rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + padding: 0 0.25rem; +} + +.live-view-section > header > span { + display: grid; + width: 1.7rem; + height: 1.7rem; + place-items: center; + border-radius: 0.55rem; + background: var(--material-deep); + color: var(--text-muted); + font-size: 0.62rem; + font-weight: 800; +} + +.live-view-section > header div, +.live-view-usage > span { + display: grid; + gap: 0.1rem; + min-width: 0; +} + +.live-view-section > header small, +.live-view-usage small { + color: var(--text-faint); + font-size: 0.62rem; +} + +.live-view-section > div { + display: grid; + gap: 0.35rem; +} + +.live-view-usage { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 0.65rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--panel-strong); + color: inherit; + text-align: left; +} + +.live-view-usage:hover { + border-color: color-mix(in srgb, var(--gold) 38%, var(--line)); + background: color-mix(in srgb, var(--gold) 5%, var(--panel-strong)); +} + +.live-view-usage.selected { + border-color: color-mix(in srgb, var(--gold) 68%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel-strong)); + box-shadow: none; +} + +.live-view-usage.current:not(.selected) { + border-color: color-mix(in srgb, var(--gold) 32%, var(--line)); +} + +.live-view-usage time { + color: var(--text-muted); + font-size: 0.65rem; +} + +.live-view-content { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding: 0; +} + +.live-view-content-header > strong { + color: var(--text-muted); + font-size: 0.72rem; + white-space: nowrap; +} + +.live-view-content-scroll { + padding: clamp(1rem, 2vw, 1.4rem); +} + +.live-view-content .live-script-document { + font-size: var(--prompt-size); +} + +.live-view-content .live-script-section { + border-radius: var(--radius-large); +} + +.live-view-empty { + display: grid; + margin: auto; + justify-items: center; + padding: 2rem; + color: var(--text-muted); + text-align: center; +} + +.live-view-details.stream-inspector { + position: static; + top: auto; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.live-view-details .stream-inspector-header { + display: block; +} + +.live-view-facts, +.live-view-settings { + display: grid; + gap: 0.65rem; + padding-bottom: 0.9rem; + border-bottom: 1px solid var(--line); +} + +.live-view-facts h3, +.live-view-settings h3 { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.live-view-facts dl { + display: grid; + gap: 0.45rem; + margin: 0; +} + +.live-view-facts dl > div { + display: flex; + justify-content: space-between; + gap: 0.75rem; + padding-bottom: 0.45rem; + border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); + font-size: 0.7rem; +} + +.live-view-facts dt { + color: var(--text-muted); +} + +.live-view-facts dd { + margin: 0; + font-weight: 750; + text-align: right; +} + +.live-view-settings .font-control { + justify-self: start; +} + +@media (max-width: 72rem) { + .live-view-page { + padding-inline: 1rem; + } + + .live-view-switch { + display: flex; + margin-bottom: 0.75rem; + } + + .live-view-workspace { + display: block; + height: auto; + min-height: calc(100dvh - 15rem); + } + + .live-view-workspace > :is(.live-view-rundown, .live-view-content, .live-view-details) { + height: auto; + min-height: min(42rem, calc(100dvh - 15rem)); + } + + .live-view-workspace > .mobile-hidden { + display: none; + } + + .live-view-rundown-scroll, + .live-view-content-scroll { + overflow: visible; + } +} + +@media (max-width: 52rem) { + .live-view-page { + padding-inline: 0.75rem; + padding-bottom: calc(var(--mobile-nav) + 1rem); + } + + .live-view-heading { + align-items: flex-start; + flex-direction: column; + } + + .live-view-heading .show-summary { + width: 100%; + } + + .live-readonly-state { + width: 100%; + justify-content: center; + } +} + +/* Stable application geometry: navigation starts at the top, page heading stays free. */ +.unified-workspace, +.fundus-list-workspace.unified-workspace, +.post-detail-page, +.live-view-page { + display: grid; + width: 100%; + max-width: none; + min-height: calc(100dvh - 4.25rem); + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr) clamp(19rem, 23vw, 22rem); + grid-template-rows: auto minmax(0, 1fr); + gap: 0.85rem; + margin: 0; + padding: 0.85rem; +} + +.unified-workspace > :is(.fundus-navigation, .workspace-navigation), +.post-detail-page .post-navigation, +.live-view-page .live-view-rundown { + grid-column: 1; + grid-row: 1 / 3; +} + +.unified-workspace > :is(.fundus-list-pane, .workspace-content-pane), +.post-page-workspace, +.live-view-workspace { + display: contents; +} + +.unified-workspace > :is(.fundus-list-pane, .workspace-content-pane) > .page-heading, +.inbox-workspace > .workspace-content-pane > header, +.post-detail-page > .post-detail-heading, +.live-view-page > .live-view-heading { + grid-column: 2 / 4; + grid-row: 1; + align-self: center; + min-height: 6.3rem; + margin: 0; + padding: 0.55rem 0.65rem; + border: 0; + background: transparent; + box-shadow: none; +} + +.fundus-library-content, +.inbox-catalog-content, +.post-library-content, +.post-page-workspace > .post-editor, +.live-view-workspace > .live-view-content { + grid-column: 2; + grid-row: 2; + min-width: 0; + min-height: 0; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); + box-shadow: var(--shadow-soft); +} + +.fundus-library-content, +.inbox-catalog-content, +.post-library-content { + align-self: start; + padding: 1rem; +} + +.unified-workspace > .content-quick-inspector, +.post-page-workspace > .post-inspector, +.live-view-workspace > .live-view-details { + grid-column: 3; + grid-row: 2; + position: sticky; + top: 5.1rem; + height: calc(100dvh - 12.25rem); + min-height: 32rem; +} + +.post-detail-page .post-page-workspace > .post-navigation, +.live-view-page .live-view-workspace > .live-view-rundown { + position: sticky; + top: 5.1rem; + height: calc(100dvh - 5.95rem); + min-height: 0; +} + +.post-page-workspace > .post-editor, +.live-view-workspace > .live-view-content { + height: calc(100dvh - 12.25rem); + min-height: 32rem; +} + +.post-page-workspace > .post-mobile-switch, +.live-view-page > .live-view-switch { + display: none; +} + +.live-view-content { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.live-view-content-actions { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.55rem; + padding: 0.7rem 0.9rem; + border-top: 1px solid var(--line); + background: var(--panel-strong); +} + +.live-view-content-actions > :last-child { + justify-self: end; +} + +.live-view-clock { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--text); + font-variant-numeric: tabular-nums; + font-size: 0.78rem; + font-weight: 780; +} + +.live-view-section { + gap: 0.55rem; + margin-bottom: 0.65rem; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--panel-strong) 94%, var(--bg)); +} + +.live-view-section + .live-view-section { + padding-top: 0.7rem; + border-top: 1px solid var(--line); +} + +/* The Piece editor remains recognizably modal on desktop. */ +dialog.piece-editor-dialog { + width: min(88rem, calc(100vw - 5rem)); + height: min(52rem, calc(100dvh - 5rem)); + border: 1px solid color-mix(in srgb, var(--gold) 48%, var(--line-strong)); + border-radius: 1.15rem; + box-shadow: 0 1.5rem 5rem rgb(5 12 25 / 42%); +} + +dialog:not(.piece-editor-dialog) { + opacity: 0; + transform: translate3d(0, 0.65rem, 0) scale(0.985); + transform-origin: 50% 24%; + transition: + opacity 130ms ease-in, + transform 150ms cubic-bezier(0.4, 0, 1, 1), + display 150ms allow-discrete, + overlay 150ms allow-discrete; +} + +dialog:not(.piece-editor-dialog)[open] { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + transition-duration: 180ms; + transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +@starting-style { + dialog:not(.piece-editor-dialog)[open] { + opacity: 0; + transform: translate3d(0, 0.8rem, 0) scale(0.98); + } +} + +.piece-editor-dialog[open] { + transform-origin: 50% 18%; + animation: surface-open 190ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.piece-editor-dialog.closing { + pointer-events: none; + animation: surface-close 145ms cubic-bezier(0.4, 0, 1, 1) both; +} + +dialog[open]::backdrop { + animation: backdrop-open 180ms ease-out both; +} + +.piece-editor-dialog.closing::backdrop { + animation: backdrop-close 145ms ease-in both; +} + +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-header, +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-tabs, +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-workspace, +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-footer { + animation: surface-content-open 170ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-header { + animation-delay: 18ms; +} + +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-tabs { + animation-delay: 28ms; +} + +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-workspace { + animation-delay: 38ms; +} + +dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-footer { + animation-delay: 48ms; +} + +details > :not(summary) { + animation: disclosure-open 140ms ease-out both; +} + +.mobile-pane-switch button, +.post-mobile-switch button, +.dialog-close, +.inspector-expand-button, +.fundus-navigation, +.workspace-navigation, +.builder-pane, +.stream-inspector { + transition: + border-color 140ms ease, + background-color 140ms ease, + color 140ms ease, + box-shadow 160ms ease, + transform 160ms cubic-bezier(0.2, 0.75, 0.25, 1), + opacity 140ms ease; +} + +.dialog-close:hover, +.inspector-expand-button:hover { + transform: scale(1.04); +} + +.dialog-close:active, +.inspector-expand-button:active { + transform: scale(0.96); +} + +@keyframes surface-open { + from { + opacity: 0; + transform: translate3d(0, 0.9rem, 0) scale(0.972); + box-shadow: 0 0.4rem 1.5rem rgb(5 12 25 / 18%); + } + + 70% { + transform: translate3d(0, -0.05rem, 0) scale(1.001); + } +} + +@keyframes surface-close { + to { + opacity: 0; + transform: translate3d(0, 0.55rem, 0) scale(0.985); + box-shadow: 0 0.5rem 1.8rem rgb(5 12 25 / 16%); + } +} + +@keyframes surface-content-open { + from { + opacity: 0; + transform: translate3d(0, 0.28rem, 0); + } +} + +@keyframes backdrop-open { + from { + opacity: 0; + } +} + +@keyframes backdrop-close { + to { + opacity: 0; + } +} + +@keyframes disclosure-open { + from { + opacity: 0; + transform: translateY(-0.2rem); + } +} + +@keyframes pane-enter { + from { + opacity: 0; + transform: translateY(0.35rem); + } +} + +@media (max-width: 78rem) and (min-width: 52.01rem) { + .unified-workspace, + .fundus-list-workspace.unified-workspace, + .post-detail-page, + .live-view-page { + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); + grid-template-rows: auto auto auto; + } + + .unified-workspace > :is(.fundus-navigation, .workspace-navigation), + .post-detail-page .post-navigation, + .live-view-page .live-view-rundown { + grid-row: 1 / 4; + } + + .unified-workspace > :is(.fundus-list-pane, .workspace-content-pane) > .page-heading, + .inbox-workspace > .workspace-content-pane > header, + .post-detail-page > .post-detail-heading, + .live-view-page > .live-view-heading { + grid-column: 2; + } + + .unified-workspace > .content-quick-inspector, + .post-page-workspace > .post-inspector, + .live-view-workspace > .live-view-details { + grid-column: 2; + grid-row: 3; + position: static; + width: 100%; + height: auto; + min-height: 30rem; + margin: 0; + } + + .post-page-workspace > .post-editor, + .live-view-workspace > .live-view-content { + height: auto; + min-height: 38rem; + } + + .post-page-workspace > .mobile-hidden, + .live-view-workspace > .mobile-hidden { + display: grid; + } +} + +@media (max-width: 52rem) { + .unified-workspace, + .fundus-list-workspace.unified-workspace, + .post-detail-page, + .live-view-page { + display: block; + min-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav)); + padding: 0.55rem 0.75rem calc(var(--mobile-nav) + 1rem); + } + + .unified-workspace > :is(.fundus-list-pane, .workspace-content-pane), + .post-page-workspace, + .live-view-workspace { + display: block; + } + + .unified-workspace > :is(.fundus-list-pane, .workspace-content-pane) > .page-heading, + .inbox-workspace > .workspace-content-pane > header, + .post-detail-page > .post-detail-heading, + .live-view-page > .live-view-heading { + min-height: 0; + padding: 0.65rem 0.25rem 0.9rem; + } + + .fundus-library-content, + .inbox-catalog-content, + .post-library-content { + padding: 0.75rem; + } + + .post-page-workspace > .post-mobile-switch, + .live-view-page > .live-view-switch { + display: flex; + margin-bottom: 0.75rem; + } + + .post-page-workspace > :is(.post-navigation, .post-editor, .post-inspector), + .live-view-workspace > :is(.live-view-rundown, .live-view-content, .live-view-details) { + position: static; + width: 100%; + height: auto; + min-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 12rem); + } + + .post-page-workspace > :is(.post-navigation, .post-editor, .post-inspector):not(.mobile-hidden), + .live-view-workspace + > :is(.live-view-rundown, .live-view-content, .live-view-details):not(.mobile-hidden) { + animation: pane-enter 160ms cubic-bezier(0.2, 0.75, 0.25, 1) both; + } + + .live-view-content-actions { + grid-template-columns: 1fr 1fr; + position: sticky; + bottom: var(--mobile-nav); + z-index: 5; + } + + .live-view-content-actions form { + grid-column: 1 / 3; + grid-row: 1; + } + + .live-view-content-actions form button { + width: 100%; + justify-content: center; + } + + dialog.piece-editor-dialog { + width: calc(100vw - 1rem); + height: calc(100dvh - 1rem); + border: 1px solid var(--line); + border-radius: 1rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .piece-editor-dialog[open], + dialog[open], + .piece-editor-dialog.closing, + dialog[open]::backdrop, + .piece-editor-dialog.closing::backdrop, + dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-header, + dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-tabs, + dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-workspace, + dialog.piece-editor-dialog[open]:not(.closing) > .piece-dialog-footer, + details > :not(summary), + .post-page-workspace > :is(.post-navigation, .post-editor, .post-inspector):not(.mobile-hidden), + .live-view-workspace + > :is(.live-view-rundown, .live-view-content, .live-view-details):not(.mobile-hidden) { + animation: none; + } + + dialog:not(.piece-editor-dialog) { + transition: none; + } + + .mobile-pane-switch button, + .post-mobile-switch button, + .dialog-close, + .inspector-expand-button, + .fundus-navigation, + .workspace-navigation, + .builder-pane, + .stream-inspector { + transition: none; + } +} + +@media (max-width: 52rem) { + .post-detail-page { + padding-inline: 0.75rem; + } + + .post-detail-heading { + padding-inline: 0.25rem; + } +} diff --git a/src/lib/components/AppIcon.svelte b/src/lib/components/AppIcon.svelte index 4c5bd1c..2f5c9a6 100644 --- a/src/lib/components/AppIcon.svelte +++ b/src/lib/components/AppIcon.svelte @@ -1,5 +1,5 @@ ; }; topics: { id: string; name: string; iconName: string }[]; + formats?: { id: string; name: string }[]; + categories?: { id: string; name: string }[]; + editor?: { + current: { + id: string; + title: string; + status: string; + durationSeconds: number; + isFavorite: boolean; + }; + pieces: { + id: string; + title: string; + status: string; + durationSeconds: number; + isFavorite: boolean; + }[]; + search: string; + }; + oneditorclose?: () => void; } = $props(); let collapsed = $state(false); let searchInput = $state(); @@ -92,13 +126,31 @@ active: false } ]); + let editorPieces = $derived( + editor + ? editor.pieces.some((piece) => piece.id === editor.current.id) + ? editor.pieces + : [editor.current, ...editor.pieces] + : [] + ); + const statusLabels: Record = { + inbox: 'Eingang', + preparing: 'In Vorbereitung', + ready: 'Sendefertig', + archived: 'Archiviert' + }; -
-

Bibliothek

-

Pieces

+

{editor ? 'Aktuelle Auswahl' : 'Bibliothek'}

+

{editor ? 'Piece wechseln' : 'Pieces'}

+ {#if editor && oneditorclose}{/if} - {#if collapsed} + {#if editor} + Zur Übersicht + {#if !collapsed} + +
+ Filter +
+ {#if filters.query}{/if} + {#if filters.view}{/if} + {#if filters.includeUsed}{/if} + {#if filters.includePlanned}{/if} + + + + + + +
+
+ {/if} + + {:else if collapsed} - - - - - - - - {#if visiblePieces.length} -
- {#each visiblePieces as piece (piece.id)} -
- -
- - - +
+ + {#if data.filters.view}{/if} +
+ Weitere Filter +
+ - - {#if piece.mediaId}{/if} -
-

- {piece.title} -

- {#if piece.summary}

{piece.summary}

{/if} - -
-
- {piece.categories.map((item) => item.name).join(', ') || 'Offen'}Format -
- {#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} + -
-
- Abschnitt -
-
- -
-
- -
-
- !confirm( - `Piece „${piece.title}“ löschen? Bestehende Streamfassungen bleiben erhalten.` - ) && event.preventDefault()} - > - +
+
-
-
- {/each} +
+ + +
+
+ + + + + {#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.svelte b/src/routes/fundus/[id]/+page.svelte index d6fc0b1..868c878 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -7,7 +7,6 @@ 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'; @@ -96,11 +95,16 @@ let pendingHref: string | null = null; let allowNavigation = false; - const statusLabels = { - inbox: 'Eingang', - preparing: 'In Vorbereitung', - ready: 'Sendefertig' - }; + $effect(() => { + const incoming = data.piece; + if (incoming.revision === revision || !['idle', 'saved'].includes(saveState) || saving) return; + draft = { ...incoming }; + durationMinutes = Number((incoming.durationSeconds / 60).toFixed(2)); + revision = incoming.revision; + sources = incoming.sources.map((source) => ({ ...source })); + pieceTools = incoming.tools.map((tool) => ({ ...tool })); + rendered = { ...data.rendered }; + }); onMount(() => editorDialog.showModal()); onDestroy(() => clearTimeout(timer)); @@ -119,7 +123,12 @@ function scheduleSave(event?: Event) { const control = event?.target; - if (event?.type === 'change' && !(control instanceof HTMLSelectElement)) return; + if ( + event?.type === 'change' && + !(control instanceof HTMLSelectElement) && + !(control instanceof HTMLInputElement && ['radio', 'checkbox'].includes(control.type)) + ) + return; if ( control instanceof HTMLInputElement && control.name === 'presentationMode' && @@ -135,6 +144,15 @@ timer = setTimeout(submitSave, 900); } + function schedulePresentationModeSave(mode: string) { + if ( + (['link', 'video'].includes(mode) && draft.presentationSourcePosition === null) || + (mode === 'image' && !draft.presentationMediaId) + ) + return; + scheduleSave(); + } + function submitSave() { if (saving) return; editorForm.requestSubmit(saveButton); @@ -254,12 +272,7 @@ {draft.title} | Pieces | Sendewerk -
- +
{#if form?.error} -
+ -
- - Status, Dauer und Einordnung - {statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min. - +
+
+

Details

+ Intern +
-
Themen
-
- Vorgeschlagene Abschnittskategorien -
- {#each data.taxonomy.categories as item (item.id)}{/each} -
-
-
+
+ Weitere Details +
+ + +
+ Vorgeschlagene Abschnittskategorien +
+ {#each data.taxonomy.categories as item (item.id)}{/each} +
+
+
+
+ -
+
-
+

Öffentliche Ausgabe

@@ -562,25 +681,29 @@ name="presentationMode" value={mode[0]} bind:group={draft.presentationMode} + onchange={() => schedulePresentationModeSave(mode[0])} />{mode[1]}{/each}
{#if draft.presentationMode === 'text'} - - +
+

Publikumsvorschau · direkt bearbeiten

+ + +
{:else} @@ -703,7 +826,8 @@ - - {#snippet footer()} -
-
- Als Vorlage speichern -
- - - - -
-
-
- -
-
- -
-
- !confirm('Piece löschen? Bestehende Streamfassungen bleiben erhalten.') && - event.preventDefault()} - > - -
-
-
- {saveMessage} - -
- {/snippet}
unknown) { - try { - return { intent, success: true, result: operation() }; - } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent, ...problem }); - } -} export const load: PageServerLoad = ({ locals, url }) => { - const actor = requireUser(locals.user); + requireUser(locals.user); const showId = url.searchParams.get('show') || listShows()[0]?.id; if (!showId) error(404, 'Kein Stream vorhanden'); try { - const live = getLiveView(showId); - return { - live, - outputStatus: getPresentationChannelStatus(showId), - canManageShares: actor.role === 'barkeeper', - shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [], - commandId: randomUUID(), - completion: - live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null - }; + return { live: getLiveView(showId), commandId: randomUUID() }; } catch (cause) { - if (cause instanceof Error && cause.message === 'NOT_FOUND') + if (cause instanceof Error && cause.message === 'NOT_FOUND') { error(404, 'Stream nicht gefunden'); + } throw cause; } }; export const actions = { - createShare: async ({ locals, request }) => { - const actor = requireBarkeeper(locals.user); - const data = await request.formData(); - try { - const share = await createShare(actor, { - showId: text(data, 'showId'), - scope: text(data, 'scope'), - password: text(data, 'password'), - expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') - }); - return { - intent: 'createShare', - success: 'Leselink angelegt.', - shareUrl: `/freigabe/${share.token}` - }; - } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent: 'createShare', ...problem }); - } - }, - updateShare: async ({ locals, request }) => { - const actor = requireBarkeeper(locals.user); - const data = await request.formData(); - try { - await updateShare( - actor, - text(data, 'shareId'), - { - scope: text(data, 'scope'), - passwordMode: text(data, 'passwordMode'), - password: text(data, 'password'), - expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') - }, - text(data, 'showId') - ); - return { intent: 'updateShare', success: 'Freigabe aktualisiert.' }; - } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent: 'updateShare', ...problem }); - } - }, - replaceShare: async ({ locals, request }) => { - const actor = requireBarkeeper(locals.user); - const data = await request.formData(); - try { - const token = replaceShareToken(actor, text(data, 'shareId'), text(data, 'showId')); - return { - intent: 'replaceShare', - success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.', - shareUrl: `/freigabe/${token}` - }; - } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent: 'replaceShare', ...problem }); - } - }, - deleteShare: async ({ locals, request }) => { - const actor = requireBarkeeper(locals.user); - const data = await request.formData(); - try { - deleteShare(actor, text(data, 'shareId'), text(data, 'showId')); - return { intent: 'deleteShare', success: 'Freigabe gelöscht.' }; - } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent: 'deleteShare', ...problem }); - } - }, - start: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('start', () => - startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - pause: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('pause', () => - pauseLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - resume: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('resume', () => - resumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - resetSectionTimer: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('resetSectionTimer', () => - resetSectionTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - resetTotalTimer: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('resetTotalTimer', () => - resetTotalTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - setStatus: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - const status = text(data, 'status'); - if (status !== 'draft' && status !== 'ready') { - return fail(400, { intent: 'setStatus', error: 'Der Streamstatus ist ungültig.' }); - } - return attempt('setStatus', () => - setShowStatus( - text(data, 'showId'), - text(data, 'commandId'), - Number(data.get('revision')), - Number(data.get('showRevision')), - status - ) - ); - }, - end: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('end', () => - endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - finalize: async ({ locals, request }) => { - const actor = requireUser(locals.user); - const data = await request.formData(); - return attempt('finalize', () => - finalizeShow({ - showId: text(data, 'showId'), - commandId: text(data, 'commandId'), - expectedShowRevision: Number(data.get('showRevision')), - expectedLiveRevision: Number(data.get('revision')), - archivePieceIds: data.getAll('archivePieceIds').map(String), - actor - }) - ); - }, - resumeEnded: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('resumeEnded', () => - resumeEndedLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - startNewRun: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('startNewRun', () => - startNewLiveRun(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - complete: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('complete', () => - completeCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - skip: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('skip', () => - skipCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - back: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('back', () => - goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) - ); - }, - select: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('select', () => - selectUsage( - text(data, 'showId'), - text(data, 'usageId'), - text(data, 'commandId'), - Number(data.get('revision')) - ) - ); - }, - resumeUsage: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('resumeUsage', () => - resumeUsage( - text(data, 'showId'), - text(data, 'usageId'), - text(data, 'commandId'), - Number(data.get('revision')) - ) - ); - }, present: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); - return attempt('present', () => + try { presentUsage( - text(data, 'showId'), - text(data, 'usageId') || null, - text(data, 'commandId'), + String(data.get('showId') || ''), + String(data.get('usageId') || '') || null, + String(data.get('commandId') || ''), Number(data.get('revision')) - ) - ); - }, - moveUsage: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - return attempt('moveUsage', () => - moveOpenUsage(text(data, 'showId'), text(data, 'usageId'), Number(data.get('showRevision')), { - sectionId: text(data, 'sectionId') || null, - position: Number(data.get('position')) - }) - ); - }, - createOutput: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - try { - const token = issuePresentationAccess(text(data, 'showId')); - return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/default#${token}` }; + ); + return { intent: 'present', success: true }; } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent: 'createOutput', ...problem }); + const code = cause instanceof Error ? cause.message : ''; + return fail(code === 'CONFLICT' ? 409 : 400, { + intent: 'present', + error: + code === 'CONFLICT' + ? 'Der Ausgabestand wurde bereits geändert.' + : 'Die Präsentation konnte nicht geändert werden.' + }); } - }, - rotateOutput: async ({ locals, request }) => { - requireUser(locals.user); - const data = await request.formData(); - try { - const token = rotatePresentationChannelKey(text(data, 'showId')); - return { intent: 'rotateOutput', success: true, outputUrl: `/heinzlive/default#${token}` }; - } catch (cause) { - const problem = actionError(cause); - return fail(problem.status, { intent: 'rotateOutput', ...problem }); - } - }, - clearOutput: async ({ locals }) => { - requireUser(locals.user); - return attempt('clearOutput', () => clearPresentationChannel()); } } satisfies Actions; diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index 2dda94f..8d7249d 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -1,349 +1,113 @@ - - Live | {data.live.show.title} | Sendewerk -
-
-
-
- - -
- Danach: - -
-
- +
+
+
+ Streams +

{data.live.show.title}

+

{berlinDate(data.live.show.startsAtUtc)}

- -
-
-
- -
- Ausgabe - {presentedUsage?.title ?? 'Leer'} -
-
- {#if data.live.presentationUsageId} - - - - - - {/if} +
+
{data.live.totalCount}Pieces
+
+ {formatDuration(data.live.show.totalDurationSeconds)}Planzeit
- -
- - {#if data.live.startedAtUtc && !data.live.endedAtUtc}
- -
{ - if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault(); - }} - > - - - - -
-
{/if} -
- -
-
= 0} - class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0} - class="section-countdown" - aria-live="off" - > - Abschnitt - - {#if hasActiveSectionTimer} - {sectionRemainingSeconds < 0 - ? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}` - : formatCountdown(sectionRemainingSeconds)} - {:else}Kein Countdown{/if} - -
- {#if data.live.startedAtUtc && !data.live.endedAtUtc}
- - - - -
{/if} - {#if data.live.startedAtUtc && !data.live.endedAtUtc && data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}
- -
{ - if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault(); - }} - > - - - - -
-
{/if} -
- -
- {#if menuOpen}
-
- Ansicht -
-
- - -
- - -
-
- {#if data.canManageShares}
- Teilen - -
{/if} -
-
- - Ausgabe einrichten - {data.outputStatus === 'connected' - ? 'Mit diesem Stream verbunden' - : data.outputStatus === 'other_show' - ? 'Mit einem anderen Stream verbunden' - : 'Nicht verbunden'} - - -
-

Der OBS-Ausgabekanal ändert sich erst durch eine der folgenden Aktionen.

-
- - -
- {#if data.outputStatus === 'connected'} -
{ - if ( - !confirm( - 'Schlüssel erneuern? Bereits eingerichtete OBS-Quellen verlieren den Zugriff.' - ) - ) - event.preventDefault(); - }} - > - - -
-
{ - if ( - !confirm( - 'Ausgabekanal trennen? Verbundene Browserquellen zeigen anschließend keinen Stream.' - ) - ) - event.preventDefault(); - }} - > - -
- {/if} -
-
-
- -
{/if} - -
- {#if form?.error}{/if} - {#if popupMessage}
{popupMessage}
{/if} + Ablauf + + +
- -
-
-

Unveränderliche Historie

-

Stream abschließen

-
-

- Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als - gesendet. -

- {#if data.completion} -
- {data.completion.doneCount} gesendet{data.completion.skippedCount} übersprungen{data.completion.openCount} offen +
+
- -
- {#if viewedUsage}
-
-
-
- Piece {viewedPosition || '–'} von {data.live.totalCount} - {#if viewedSection}{viewedSection.name}{/if} + {data.live.completedCount}/{data.live.totalCount} + +
+ {#each data.live.sections as section, sectionIndex (section.id)} +
+
+ {String(sectionIndex + 1).padStart(2, '0')} +
+ {section.name}{section.usages.length} Pieces · {formatDuration(section.durationSeconds)} +
+
+
+ {#each section.usages as usage (usage.id)} + + {/each} + {#if !section.usages.length}

+ Keine Pieces in diesem Abschnitt. +

{/if}
-

{viewedUsage.title}

- {#if showTimes} - {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)} - {/if} +
+ {/each} + {#if data.live.reserve.length} +
+
+ R +
Reserve{data.live.reserve.length} Pieces
+
+
+ {#each data.live.reserve as usage (usage.id)} + + {/each} +
+
+ {/if} +
+ + +
+ {#if selected} +
+
+

{selectedSection?.name ?? 'Reserve'}

+

{selected.title}

- {#if viewedSection?.twitchTags.length}
+
+ {#if selectedSection?.twitchTags.length} + - - {viewedSection.twitchTags.join(' · ')} - {/if} - - {#if viewedUsage.introHtml}
- Einleitung - - -
{@html viewedUsage.introHtml}
-
{/if} - {#if viewedUsage.moderationHtml}
- Moderationsstichpunkte - -
{@html viewedUsage.moderationHtml}
-
{/if} - {#if viewedUsage.interactionHtml}
-

Interaktion

- -
{@html viewedUsage.interactionHtml}
-
{/if} - {#if viewedUsage.transitionHtml}
- Überleitung - -
{@html viewedUsage.transitionHtml}
-
{/if} - - {#if viewedUsage.researchHtml || viewedUsage.sources.length || viewedUsage.tools.length || viewedUsage.ownNotes} - - + {/if} +
+ {#if selected.introHtml}
+ Einleitung + + +
{@html selected.introHtml}
+
{/if} + {#if selected.moderationHtml}
+ Moderationsstichpunkte + +
{@html selected.moderationHtml}
+
{/if} + {#if selected.interactionHtml}
+ Interaktion + +
{@html selected.interactionHtml}
+
{/if} + {#if selected.transitionHtml}
+ Überleitung + +
{@html selected.transitionHtml}
+
{/if} + {#if !selected.introHtml && !selected.moderationHtml && !selected.interactionHtml && !selected.transitionHtml}

+ Für dieses Piece sind keine Moderationstexte hinterlegt. +

{/if} +
+ {#if selected.researchHtml || selected.sources.length || selected.tools.length || selected.ownNotes}
- {#if viewedUsage.researchHtml}
+ {#if selected.researchHtml}
Recherche -
{@html viewedUsage.researchHtml}
+
{@html selected.researchHtml}
{/if} - {#if viewedUsage.sources.length}
- Quellen · {viewedUsage.sources.length} + + + {#if selected.sources.length}
+ Quellen · {selected.sources.length}
    - {#each viewedUsage.sources as source (source.id)}
  • - - + {#each selected.sources as source (source.id)}
  • {source.label} - {#if source.timecodeSeconds !== null}{#if source.timecodeSeconds !== null}ab {formatClock(source.timecodeSeconds)}{/if}
  • {/each}
{/if} - {#if viewedUsage.tools.length}
- Werkzeuge · {viewedUsage.tools.length} + {#if selected.tools.length}
+ Werkzeuge · {selected.tools.length}
    - {#each viewedUsage.tools as tool (tool.id)}
  • - {#if tool.workUrl || tool.startUrl} - - + {#if tool.workUrl || tool.startUrl}{tool.name} - {:else}{tool.name}Kein Link hinterlegt{/if} - {#if tool.note}{tool.note}{/if} + >{:else}{tool.name}{/if}{#if tool.note}{tool.note}{/if}
  • {/each}
{/if} - {#if viewedUsage.ownNotes}
+ {#if selected.ownNotes}
Eigene Notizen -

{viewedUsage.ownNotes}

+

{selected.ownNotes}

{/if} +
- - {/if} - {#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.interactionHtml && !viewedUsage.transitionHtml} -

Für dieses Piece sind keine Moderationstexte hinterlegt.

{/if}
-
{:else}
-
-

{sessionComplete ? 'Ablauf abgeschlossen' : 'Live-Stand'}

-

- {sessionComplete - ? 'Alle Pieces bearbeitet' - : data.live.totalCount - ? 'Bereit für den Stream' - : 'Noch kein Piece eingeplant'} -

-

- {sessionComplete - ? 'Der Live-Ablauf ist am Ende. Der Stream wird hier nicht automatisch abgeschlossen.' - : data.live.totalCount - ? 'Starte oder setze den gespeicherten Live-Stand fort.' - : 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'} -

- {#if !data.live.startedAtUtc || needsResume}
+ + {#if selectedSection} + - -
{/if} -
-
{/if} - - -
- -
- -
diff --git a/src/routes/post/+page.server.ts b/src/routes/post/+page.server.ts new file mode 100644 index 0000000..48a93d8 --- /dev/null +++ b/src/routes/post/+page.server.ts @@ -0,0 +1,35 @@ +import type { PageServerLoad } from './$types'; +import { requireUser } from '#lib/server/auth.js'; +import { listPostShows } from '#lib/server/post.js'; + +export const load: PageServerLoad = ({ locals, url }) => { + requireUser(locals.user); + const query = (url.searchParams.get('q') ?? '').trim(); + const progress = url.searchParams.get('progress') ?? ''; + const sort = url.searchParams.get('sort') ?? 'newest'; + let shows = listPostShows(); + const counts = { + all: shows.length, + notStarted: shows.filter((show) => show.progress === 'Noch nicht begonnen').length, + inProgress: shows.filter((show) => show.progress === 'In Bearbeitung').length, + done: shows.filter((show) => show.progress === 'Fertig').length, + published: shows.filter((show) => show.progress === 'Veröffentlicht').length + }; + if (query) { + const needle = query.toLocaleLowerCase('de-DE'); + shows = shows.filter((show) => show.title.toLocaleLowerCase('de-DE').includes(needle)); + } + if (progress) shows = shows.filter((show) => show.progress === progress); + shows.sort((left, right) => { + if (sort === 'title') return left.title.localeCompare(right.title, 'de-DE'); + if (sort === 'oldest') + return (left.completedAtUtc ?? left.startsAtUtc ?? '').localeCompare( + right.completedAtUtc ?? right.startsAtUtc ?? '' + ); + if (sort === 'clips') return right.clipCount - left.clipCount; + return (right.completedAtUtc ?? right.startsAtUtc ?? '').localeCompare( + left.completedAtUtc ?? left.startsAtUtc ?? '' + ); + }); + return { shows, query, progress, sort, counts }; +}; diff --git a/src/routes/post/+page.svelte b/src/routes/post/+page.svelte new file mode 100644 index 0000000..48f022d --- /dev/null +++ b/src/routes/post/+page.svelte @@ -0,0 +1,153 @@ + + +Postproduktion | Sendewerk + +
+ +
+
+
+

Postproduktion

+

YouTube, Social-Clips und Twitch-Highlights vorbereiten.

+
+
+
+
+
+

Abgeschlossene Streams

+

Streams

+
+ {data.shows.length} +
+ {#if data.shows.length} +
+ {#each data.shows as show (show.showId)} + +
+ {show.progress} +

{show.title}

+

{date(show.startsAtUtc ?? show.completedAtUtc)}

+
+
+
+
Teile
+
{show.recordingPartCount}
+
+
+
Kontext
+
{show.sourceCount}
+
+
+
YouTube
+
{show.youtubeCount}
+
+
+
Clips
+
{show.clipCount}
+
+
+
Highlights
+
{show.highlightCount}
+
+
+ +
+ {/each} +
+ {:else} +
+

Keine abgeschlossene Sendung gefunden

+

Postproduktion beginnt mit einer in Live bewusst abgeschlossenen Sendung.

+
+ {/if} +
+
+ +
diff --git a/src/routes/post/[id]/+page.server.ts b/src/routes/post/[id]/+page.server.ts new file mode 100644 index 0000000..8c33923 --- /dev/null +++ b/src/routes/post/[id]/+page.server.ts @@ -0,0 +1,269 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { requireUser } from '#lib/server/auth.js'; +import { + createPostItem, + createRecordingPart, + deletePostItem, + deletePostSource, + deleteRecordingPart, + deleteThumbnail, + getPostWorkspace, + importPostSource, + listPostPieceOptions, + savePostItem, + selectThumbnail, + uploadThumbnail, + updateRecordingPart, + type ClipType, + type PostItemInput, + type PostKind, + type PostStatus +} from '#lib/server/post.js'; + +function text(data: FormData, name: string) { + return String(data.get(name) || ''); +} + +function optionalNumber(data: FormData, name: string) { + const value = text(data, name).trim(); + return value ? Number(value) : null; +} + +function problem(cause: unknown) { + const code = cause instanceof Error ? cause.message : ''; + if (code === 'CONFLICT') + return { status: 409, error: 'Eine neuere Änderung liegt vor. Bitte neu laden.' }; + if (code === 'DUPLICATE_SOURCE') + return { status: 409, error: 'Diese Kontextdatei wurde bereits importiert.' }; + if (code === 'INVALID_SOURCE_SIZE') + return { + status: 400, + error: 'Die Markdown-Datei darf höchstens 5 MB groß und nicht leer sein.' + }; + if (code === 'INVALID_SOURCE_TYPE' || code === 'INVALID_SOURCE_ENCODING') + return { status: 400, error: 'Bitte eine UTF-8-kodierte Markdown-Datei (.md) auswählen.' }; + if (code === 'INVALID_IMAGE_SIZE') + return { status: 400, error: 'Das Thumbnail darf höchstens 5 MB groß und nicht leer sein.' }; + if (code === 'INVALID_IMAGE_TYPE') + return { status: 400, error: 'Erlaubt sind JPEG, PNG und WebP.' }; + if (code.startsWith('INVALID_')) + return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; + return { status: 400, error: 'Die Änderung konnte nicht gespeichert werden.' }; +} + +export const load: PageServerLoad = ({ locals, params }) => { + requireUser(locals.user); + try { + return { workspace: getPostWorkspace(params.id), pieces: listPostPieceOptions(params.id) }; + } catch (cause) { + if (cause instanceof Error && cause.message === 'NOT_FOUND') + error(404, 'Sendung nicht gefunden'); + throw cause; + } +}; + +export const actions = { + createItem: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + let id: string; + try { + id = createPostItem(params.id, text(data, 'kind') as PostKind, text(data, 'title')); + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'createItem', ...issue }); + } + redirect(303, `/post/${params.id}?item=${encodeURIComponent(id)}`); + }, + saveItem: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + const publishedDate = text(data, 'publishedDate'); + const input: PostItemInput = { + title: text(data, 'title'), + status: text(data, 'status') as PostStatus, + recordingPartId: text(data, 'recordingPartId') || null, + sourceFileId: text(data, 'sourceFileId') || null, + pieceId: text(data, 'pieceId') || null, + notes: text(data, 'notes'), + description: text(data, 'description'), + titleVariants: text(data, 'titleVariants') + .split('\n') + .map((value) => value.trim()) + .filter(Boolean), + thumbnailConcept: text(data, 'thumbnailConcept'), + thumbnailText: text(data, 'thumbnailText'), + publicationStatus: text(data, 'publicationStatus') as PostItemInput['publicationStatus'], + publishedAtUtc: publishedDate ? `${publishedDate}T12:00:00.000Z` : null, + publicationUrl: text(data, 'publicationUrl'), + videoFormat: text(data, 'videoFormat'), + clipType: text(data, 'clipType') as ClipType, + originalTranscript: text(data, 'originalTranscript'), + startSeconds: optionalNumber(data, 'startSeconds'), + endSeconds: optionalNumber(data, 'endSeconds'), + dramaturgyNotes: text(data, 'dramaturgyNotes'), + editInstructions: text(data, 'editInstructions'), + shortsTitle: text(data, 'shortsTitle'), + shortsDescription: text(data, 'shortsDescription'), + shortsHashtags: text(data, 'shortsHashtags'), + socialCaption: text(data, 'socialCaption'), + tiktokCaption: text(data, 'tiktokCaption'), + instagramCaption: text(data, 'instagramCaption'), + socialHashtags: text(data, 'socialHashtags'), + primaryHook: text(data, 'primaryHook'), + alternativeHooks: text(data, 'alternativeHooks'), + endcardText: text(data, 'endcardText'), + alternativeEndcards: text(data, 'alternativeEndcards'), + framingNotes: text(data, 'framingNotes'), + subtitleNotes: text(data, 'subtitleNotes'), + subtitleSrt: text(data, 'subtitleSrt'), + targetTikTok: data.get('targetTikTok') === '1', + targetInstagram: data.get('targetInstagram') === '1', + targetShorts: data.get('targetShorts') === '1', + publicationNotes: text(data, 'publicationNotes') + }; + try { + return { + intent: 'saveItem', + saved: true, + ...savePostItem(params.id, text(data, 'itemId'), Number(data.get('revision')), input) + }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'saveItem', ...issue }); + } + }, + deleteItem: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + deletePostItem(params.id, text(data, 'itemId'), Number(data.get('revision'))); + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'deleteItem', ...issue }); + } + redirect(303, `/post/${params.id}`); + }, + createPart: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + createRecordingPart(params.id, { + label: text(data, 'label'), + mediaFilePath: text(data, 'mediaFilePath'), + frameRate: optionalNumber(data, 'frameRate'), + timebase: optionalNumber(data, 'timebase') + }); + return { intent: 'createPart', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'createPart', ...issue }); + } + }, + updatePart: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + updateRecordingPart(params.id, text(data, 'partId'), { + label: text(data, 'label'), + mediaFilePath: text(data, 'mediaFilePath'), + frameRate: optionalNumber(data, 'frameRate'), + timebase: optionalNumber(data, 'timebase') + }); + return { intent: 'updatePart', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'updatePart', ...issue }); + } + }, + deletePart: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + deleteRecordingPart(params.id, text(data, 'partId')); + return { intent: 'deletePart', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'deletePart', ...issue }); + } + }, + importSource: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + const source = data.get('source'); + if (!(source instanceof File)) + return fail(400, { intent: 'importSource', error: 'Bitte eine Markdown-Datei auswählen.' }); + try { + await importPostSource(params.id, text(data, 'recordingPartId') || null, source); + return { intent: 'importSource', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'importSource', ...issue }); + } + }, + deleteSource: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + await deletePostSource(params.id, text(data, 'sourceId')); + return { intent: 'deleteSource', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'deleteSource', ...issue }); + } + }, + uploadThumbnail: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + const image = data.get('image'); + if (!(image instanceof File)) + return fail(400, { intent: 'uploadThumbnail', error: 'Bitte ein Bild auswählen.' }); + try { + await uploadThumbnail( + params.id, + text(data, 'itemId'), + Number(data.get('revision')), + image, + text(data, 'altText'), + data.get('select') === '1' + ); + return { intent: 'uploadThumbnail', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'uploadThumbnail', ...issue }); + } + }, + selectThumbnail: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + selectThumbnail( + params.id, + text(data, 'itemId'), + text(data, 'thumbnailId'), + Number(data.get('revision')) + ); + return { intent: 'selectThumbnail', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'selectThumbnail', ...issue }); + } + }, + deleteThumbnail: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + await deleteThumbnail( + params.id, + text(data, 'itemId'), + text(data, 'thumbnailId'), + Number(data.get('revision')) + ); + return { intent: 'deleteThumbnail', success: true }; + } catch (cause) { + const issue = problem(cause); + return fail(issue.status, { intent: 'deleteThumbnail', ...issue }); + } + } +} satisfies Actions; diff --git a/src/routes/post/[id]/+page.svelte b/src/routes/post/[id]/+page.svelte new file mode 100644 index 0000000..083b189 --- /dev/null +++ b/src/routes/post/[id]/+page.svelte @@ -0,0 +1,1144 @@ + + +{data.workspace.show.title} | Postproduktion | Sendewerk + +
+
+
+ Postproduktion +

{data.workspace.show.title}

+

+ {berlinDate(data.workspace.show.startsAtUtc ?? data.workspace.show.completedAtUtc)} +

+
+
+
+ {data.workspace.items.length}Post-Inhalte +
+
+ {data.workspace.sources.length}Kontextdateien +
+
+
+
+ + + + +
+
+
+

+ {selected?.kind === 'youtube' + ? 'YouTube-Uncut' + : selected?.kind === 'clip' + ? 'Social-Clip' + : selected?.kind === 'highlight' + ? 'Twitch-Highlight' + : 'Postproduktion'} +

+

{selected?.title ?? data.workspace.show.title}

+
+
+ {copyMessage || (form?.saved ? 'Gespeichert' : '')} + {#if selected}{/if} +
+
+
+ +
+ + {#if form?.error}{/if} + + {#if selected} +
+ + +
+ + + {#if selected.kind === 'youtube'} +
+
+
+

Veröffentlichungstext

+

Videobeschreibung

+
+ +
+ +
+
+ Titelvarianten + +
+
+
+
+

16:9

+

Thumbnails

+
+ {selected.thumbnails.length} Varianten +
+ {#if selected.thumbnails.length} +
+ {#each selected.thumbnails as thumbnail (thumbnail.id)} +
+ {thumbnail.altText} +
+ {thumbnail.originalName}{#if thumbnail.isSelected}Ausgewählt{/if} +
+ Herunterladen + {#if !thumbnail.isSelected}
+ +
{/if} +
+ !confirm('Thumbnail-Variante löschen?') && event.preventDefault()} + > + +
+
+
+
+ {/each} +
+ {/if} +
+ + + + + +
+
+ + +
+
+ {:else if selected.kind === 'clip'} + + + + + + {:else} +
+
+
+

Twitch

+

Beschreibung

+
+ +
+ +
+
+
+
+

Wortgetreu

+

Originaltranskript

+
+ +
+ +
+
+ +
+ {/if} +
+ {:else} +
+ {#each [{ kind: 'youtube', label: 'YouTube', items: youtubeItems }, { kind: 'clip', label: 'Social-Clips', items: clipItems }, { kind: 'highlight', label: 'Twitch-Highlights', items: highlightItems }] as group (group.kind)} +
+
+
+

{itemKindLabel(group.kind as PostItem['kind'])}

+

{group.label}

+
+ {group.items.length} +
+ {#if group.items.length} +
+ {#each group.items as item (item.id)} +
+ + {#if item.kind === 'youtube' && item.description}

+ {item.description} +

{/if} + {#if item.kind === 'clip'} +
+ {#if item.shortsTitle}{/if} + {#if item.socialCaption}{/if} +
+ {/if} +
+ {/each} +
+ {:else}

+ Noch keine Inhalte angelegt. Links lässt sich direkt ein Eintrag erstellen. +

{/if} +
+ {/each} +
+ {/if} +
+ + +
+
+ + +
+
+

Sendungsmaterial

+

Aufzeichnungen & Kontextdateien

+
+ +
+
+
+

Aufzeichnungsteile

+ {#if data.workspace.recordingParts.length}
+ {#each data.workspace.recordingParts as part (part.id)}
+ {part.label}{part.mediaFilePath || 'Kein Mediendateipfad'}{#if part.frameRate} + · {part.frameRate} fps{/if}{#if part.timebase} + · Timebase {part.timebase}{/if} +
+ + + + + + +
+
+ !confirm('Aufzeichnungsteil entfernen? Zuordnungen werden gelöst.') && + event.preventDefault()} + > + +
+
{/each} +
{:else}

Noch keine Aufzeichnungsteile angelegt.

{/if} +
+ Aufzeichnungsteil hinzufügen +
+ +
+
+
+
+

Markdown-Kontextdateien

+ {#if data.workspace.sources.length}
+ {#each data.workspace.sources as source (source.id)}
+
+ {source.originalName}{Math.ceil(source.byteSize / 1024)} KB · {source.timecodes.length} erkannte Zeitmarken +
+
+ !confirm( + 'Kontextdatei entfernen? Zugeordnete Inhalte verlieren nur die Dateireferenz.' + ) && event.preventDefault()} + > + +
+
{/each} +
{:else}

Noch keine Kontextdateien importiert.

{/if} +
+ UTF-8, höchstens 5 MB. Gleiche Dateiinhalte werden nicht doppelt importiert. +
+
+
+
diff --git a/src/routes/post/[id]/export/[itemId]/[kind]/+server.ts b/src/routes/post/[id]/export/[itemId]/[kind]/+server.ts new file mode 100644 index 0000000..ceb744a --- /dev/null +++ b/src/routes/post/[id]/export/[itemId]/[kind]/+server.ts @@ -0,0 +1,30 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireUser } from '#lib/server/auth.js'; +import { getExport } from '#lib/server/post.js'; + +export const GET: RequestHandler = ({ locals, params }) => { + requireUser(locals.user); + if (!['texts', 'cut', 'resolve', 'srt'].includes(params.kind)) + error(404, 'Export nicht gefunden'); + try { + const file = getExport( + params.id, + params.itemId, + params.kind as 'texts' | 'cut' | 'resolve' | 'srt' + ); + return new Response(file.body, { + headers: { + 'content-type': file.contentType, + 'content-disposition': `attachment; filename="post-${params.itemId}.${file.extension}"`, + 'cache-control': 'private, no-store', + 'x-content-type-options': 'nosniff' + } + }); + } catch (cause) { + if (cause instanceof Error && cause.message === 'EXPORT_NOT_READY') { + error(409, 'Für diesen Export fehlen noch technisch erforderliche Angaben.'); + } + error(404, 'Export nicht gefunden'); + } +}; diff --git a/src/routes/post/[id]/source/[sourceId]/+server.ts b/src/routes/post/[id]/source/[sourceId]/+server.ts new file mode 100644 index 0000000..d1ee224 --- /dev/null +++ b/src/routes/post/[id]/source/[sourceId]/+server.ts @@ -0,0 +1,20 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireUser } from '#lib/server/auth.js'; +import { getPostSourceFile } from '#lib/server/post.js'; + +export const GET: RequestHandler = async ({ locals, params }) => { + requireUser(locals.user); + const file = await getPostSourceFile(params.id, params.sourceId); + if (!file) error(404, 'Datei nicht gefunden'); + return new Response(file.data, { + headers: { + 'content-type': 'text/markdown; charset=utf-8', + 'content-length': String(file.byteSize), + 'content-disposition': `inline; filename*=UTF-8''${encodeURIComponent(file.originalName)}`, + 'cache-control': 'private, no-store', + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox" + } + }); +}; diff --git a/src/routes/post/[id]/thumbnail/[thumbnailId]/+server.ts b/src/routes/post/[id]/thumbnail/[thumbnailId]/+server.ts new file mode 100644 index 0000000..8b1d3d3 --- /dev/null +++ b/src/routes/post/[id]/thumbnail/[thumbnailId]/+server.ts @@ -0,0 +1,22 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireUser } from '#lib/server/auth.js'; +import { getThumbnailFile } from '#lib/server/post.js'; + +export const GET: RequestHandler = async ({ locals, params, url }) => { + requireUser(locals.user); + const file = await getThumbnailFile(params.id, params.thumbnailId); + if (!file) error(404, 'Thumbnail nicht gefunden'); + const disposition = url.searchParams.get('download') === '1' ? 'attachment' : 'inline'; + return new Response(file.data, { + headers: { + 'content-type': file.mimeType, + 'content-length': String(file.byteSize), + 'content-disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(file.originalName)}`, + 'cache-control': 'private, no-store', + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; sandbox", + 'cross-origin-resource-policy': 'same-origin' + } + }); +}; diff --git a/src/routes/sendungen/[id]/+page.server.ts b/src/routes/sendungen/[id]/+page.server.ts index 01fc8b8..dd2fb0b 100644 --- a/src/routes/sendungen/[id]/+page.server.ts +++ b/src/routes/sendungen/[id]/+page.server.ts @@ -28,6 +28,7 @@ import { saveShowMetadata, listShows, updatePieceUsage, + updatePieceUsageMetadata, updateShowSection, type EditableShowStatus, type PieceUsageUpdate @@ -369,6 +370,21 @@ export const actions = { }) ); }, + updateUsageMetadata: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + return attempt('updateUsageMetadata', () => + updatePieceUsageMetadata(params.id, text(data, 'usageId'), Number(data.get('revision')), { + title: text(data, 'title'), + outputTitle: text(data, 'outputTitle'), + durationSeconds: pieceDurationInSeconds(data), + ownNotes: text(data, 'ownNotes'), + preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'], + preparationEffort: (text(data, 'preparationEffort') || + null) as PieceUsageUpdate['preparationEffort'] + }) + ); + }, duplicateUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); diff --git a/src/routes/sendungen/[id]/+page.svelte b/src/routes/sendungen/[id]/+page.svelte index 4e26b32..253b196 100644 --- a/src/routes/sendungen/[id]/+page.svelte +++ b/src/routes/sendungen/[id]/+page.svelte @@ -8,14 +8,22 @@ import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import AppIcon from '#lib/components/AppIcon.svelte'; import ShareManagement from '#lib/components/ShareManagement.svelte'; - import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; + import StreamInspector from '#lib/components/StreamInspector.svelte'; import StreamNavigation from '#lib/components/StreamNavigation.svelte'; import { formatClock, formatDuration } from '#lib/format.js'; + import type { PieceUsage, ShowSection } from '#lib/server/shows.js'; import type { PageProps } from './$types'; let { data, form }: PageProps = $props(); - let mobilePane = $state<'fundus' | 'plan'>('plan'); + type InspectorSelection = + { kind: 'show' } | { kind: 'section'; id: string } | { kind: 'usage'; id: string }; + + let initialUsageId = untrack(() => page.url.searchParams.get('usage')); + let mobilePane = $state<'fundus' | 'plan' | 'details'>(initialUsageId ? 'details' : 'plan'); let pieceLibraryOpen = $state(false); + let inspectorSelection = $state( + initialUsageId ? { kind: 'usage', id: initialUsageId } : { kind: 'show' } + ); let dragged = $state<{ kind: 'piece' | 'section' | 'usage'; id: string } | null>(null); let dropPreview = $state< | { kind: 'section'; position: number } @@ -46,6 +54,26 @@ let plannedCount = $derived( data.show.sections.reduce((total, section) => total + section.usages.length, 0) ); + let selectedSection = $derived.by(() => { + if (inspectorSelection.kind !== 'section') return null; + const sectionId = inspectorSelection.id; + return data.show.sections.find((section) => section.id === sectionId) ?? null; + }); + let selectedUsageContext = $derived.by( + (): { + usage: PieceUsage; + section: ShowSection | null; + } | null => { + if (inspectorSelection.kind !== 'usage') return null; + const usageId = inspectorSelection.id; + for (const section of data.show.sections) { + const usage = section.usages.find((item) => item.id === usageId); + if (usage) return { usage, section }; + } + const usage = data.show.reserve.find((item) => item.id === usageId); + return usage ? { usage, section: null } : null; + } + ); onMount(() => { const syncFiltersFromHistory = () => { const search = new URL(window.location.href).searchParams; @@ -88,6 +116,18 @@ dropTarget = null; } + function showInspector(selection: InspectorSelection) { + inspectorSelection = selection; + if (selection.kind !== 'show') pieceLibraryOpen = false; + if (window.matchMedia('(max-width: 72rem)').matches) mobilePane = 'details'; + } + + function selectOnKeyboard(event: KeyboardEvent, selection: InspectorSelection) { + if (!['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + showInspector(selection); + } + function draggedTitle() { if (!dragged) return ''; const draggedId = dragged.id; @@ -184,7 +224,7 @@ const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => { const affected = formElement.closest( - '.run-section, .editable-reserve, .builder-source-card, .show-settings, .add-section-form' + '.run-section, .editable-reserve, .builder-source-card, .stream-inspector, .add-section-form' ); affected?.classList.add('is-updating'); if (submitter instanceof HTMLButtonElement) submitter.disabled = true; @@ -343,60 +383,6 @@
{/if}
{copyMessage}
-
- Streamdaten und Twitch-Texte -
- - - - - - -
- {copyMessage} -
-
-
{#if data.canManageShares} (mobilePane = 'plan')}>Ablauf +
@@ -648,6 +640,8 @@ class="run-section editable-run-section" class:is-dragging={dragged?.kind === 'section' && dragged.id === section.id} class:drop-ready={dropTarget === section.id} + class:selected={inspectorSelection.kind === 'section' && + inspectorSelection.id === section.id} style={`--category:${section.categoryColor ?? '#8b8f98'}`} ondragover={(event) => { if (dragged?.kind === 'section') previewSection(event, index); @@ -657,9 +651,15 @@ >
showInspector({ kind: 'section', id: section.id })} + onkeydown={(event) => + selectOnKeyboard(event, { kind: 'section', id: section.id })} ondragstart={(event) => startDrag('section', section.id, event)} ondragend={finishDrag} > @@ -722,6 +722,8 @@
previewUsage(event, section.id, usageIndex)} @@ -732,9 +734,13 @@ >
showInspector({ kind: 'usage', id: usage.id })} + onkeydown={(event) => + selectOnKeyboard(event, { kind: 'usage', id: usage.id })} ondragstart={(event) => { event.stopPropagation(); startDrag('usage', usage.id, event); @@ -786,15 +792,13 @@ > - +
@@ -831,68 +835,6 @@ Leerer Abschnitt · Piece hinzufügen oder hierher ziehen {/if} -
- Abschnitt bearbeiten -
- -
-
- !confirm('Abschnitt samt Einplanungen entfernen?') && - event.preventDefault()} - > - -
-
{/each} @@ -986,6 +928,8 @@
previewUsage(event, null, usageIndex)} @@ -996,9 +940,12 @@ >
showInspector({ kind: 'usage', id: usage.id })} + onkeydown={(event) => selectOnKeyboard(event, { kind: 'usage', id: usage.id })} ondragstart={(event) => startDrag('usage', usage.id, event)} ondragend={finishDrag} > @@ -1039,14 +986,13 @@ >Aus Stream entfernen - +
{/each} @@ -1060,6 +1006,22 @@

{/if} +
+ {#key `${inspectorSelection.kind}-${inspectorSelection.kind === 'show' ? data.show.id : inspectorSelection.id}`} + showInspector({ kind: 'show' })} + /> + {/key} +
{ + const actor = database.prepare('SELECT id FROM users WHERE email = ?').get(adminEmail) as { + id: string; + }; + database + .prepare( + `INSERT INTO piece_usage_history + (usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot, + used_at_utc, actual_duration_seconds) + VALUES ('e2e-completion-done', 'e2e-completion', 'piece-console', 'E2E Abschluss', + 'Gesendeter Abschlussbeitrag', ?, 300)` + ) + .run(now); + database + .prepare( + `INSERT INTO show_finalizations + (show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc) + VALUES ('e2e-completion', 'e2e-fixture-finalization', 1, ?, ?)` + ) + .run(actor.id, now); + database + .prepare( + `UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = 2, + updated_at_utc = ? WHERE id = 'e2e-completion'` + ) + .run(now, now); + })(); + database.close(); +} + function removeCompletionFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) return; @@ -338,81 +394,6 @@ function removeIntegrationFixtures() { database.close(); } -function addPresentationFixture() { - const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; - if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); - const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); - database.pragma('foreign_keys = ON'); - const now = '2026-10-22T19:00:00.000Z'; - database.transaction(() => { - database - .prepare( - `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, - revision, created_at_utc, updated_at_utc) - VALUES ('e2e-presentation', 'E2E Präsentation', ?, '', '', 'ready', 1, ?, ?)` - ) - .run(now, now, now); - database - .prepare( - `INSERT INTO show_sections (id, show_id, name, category_id, position, - explicit_duration_seconds, is_reserve_bank) - VALUES ('e2e-presentation-section', 'e2e-presentation', 'Player', NULL, 0, NULL, 0), - ('e2e-presentation-reserve', 'e2e-presentation', 'Reservebank', NULL, 1, NULL, 1)` - ) - .run(); - const insert = database.prepare( - `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, - duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, - transition_snapshot, presentation_snapshot, own_notes, is_reserve, - presentation_mode_snapshot, presentation_source_label_snapshot, - presentation_source_url_snapshot, presentation_source_timecode_snapshot, - presentation_title_meta_snapshot, presentation_description_snapshot, - presentation_image_url_snapshot, presentation_site_name_snapshot, - presentation_provider_snapshot, presentation_provider_id_snapshot) - VALUES (?, 'e2e-presentation-section', NULL, ?, 'open', 300, ?, '', ?, ?, '', '', '', 0, - 'video', ?, ?, ?, ?, '', '', ?, ?, ?)` - ); - insert.run( - 'e2e-youtube', - 0, - 'YouTube Kurzlink', - 'PRIVATE_RESEARCH_YOUTUBE', - 'PRIVATE_MODERATION_YOUTUBE', - 'YouTube', - 'https://youtu.be/dQw4w9WgXcQ', - 75, - 'YouTube Vorschau', - 'YouTube', - 'youtube', - 'dQw4w9WgXcQ' - ); - insert.run( - 'e2e-tiktok', - 1, - 'TikTok Hochformat', - 'PRIVATE_RESEARCH_TIKTOK', - 'PRIVATE_MODERATION_TIKTOK', - 'TikTok', - 'https://www.tiktok.com/@scout2015/video/6718335390845095173', - 12, - 'TikTok Vorschau', - 'TikTok', - 'tiktok', - '6718335390845095173' - ); - })(); - database.close(); -} - -function removePresentationFixture() { - const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; - if (!dataDirectory) return; - const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); - database.pragma('foreign_keys = ON'); - database.prepare("DELETE FROM shows WHERE id = 'e2e-presentation'").run(); - database.close(); -} - function addCalendarFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); @@ -445,16 +426,6 @@ function removeCalendarFixture() { database.close(); } -function setSectionDuration(sectionId: string, seconds: number | null) { - const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; - if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); - const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); - database - .prepare('UPDATE show_sections SET explicit_duration_seconds = ? WHERE id = ?') - .run(seconds, sectionId); - database.close(); -} - function setUsagePreparation( usageId: string, status: 'preparing' | 'ready', @@ -511,7 +482,7 @@ test('dashboard, global search and responsive primary navigation use the shared 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']) { + for (const label of ['Start', 'Eingang', 'Pieces', 'Streams', 'Live', 'Post']) { await expect( page.locator('.main-navigation').getByRole('link', { name: label, exact: true }) ).toBeVisible(); @@ -536,11 +507,171 @@ test('dashboard, global search and responsive primary navigation use the shared await expect( page.locator('.mobile-header').getByPlaceholder('Suchen', { exact: true }) ).toBeVisible(); - await expect(page.locator('.mobile-navigation').getByRole('link')).toHaveCount(5); + await expect(page.locator('.mobile-navigation').getByRole('link')).toHaveCount(6); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('start-mobile.png'), fullPage: true }); }); +test('post-production manages completed shows, source material, videos, clips and valid exports', async ({ + page +}, testInfo) => { + test.setTimeout(60_000); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('/post'); + await expect( + page.locator('.post-page-heading').getByRole('heading', { name: 'Postproduktion' }) + ).toBeVisible(); + await expect(page.locator('.post-navigation-panel')).toHaveClass(/fundus-navigation/); + await expect(page.locator('.content-quick-inspector')).toHaveClass(/stream-inspector/); + const completedShow = page.locator('.post-show-row').filter({ + hasText: 'Frühere Sendung: Linux, Spiele und Netzkultur' + }); + await expect(completedShow).toContainText('Noch nicht begonnen'); + await expect(completedShow).toContainText('0'); + await page.screenshot({ path: testInfo.outputPath('post-overview-1440.png'), fullPage: true }); + for (const [width, height] of [ + [960, 900], + [390, 844] + ] as const) { + await page.setViewportSize({ width, height }); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath(`post-overview-${width}.png`), + fullPage: true + }); + } + await page.setViewportSize({ width: 1440, height: 1000 }); + await completedShow.click(); + await expect(page.locator('.post-workspace')).toBeVisible(); + await expect(page.locator('.post-detail-page')).toBeVisible(); + await expect(page.locator('.editor-workspace-dialog')).toHaveCount(0); + await expect(page.locator('.post-editor')).toHaveClass(/builder-pane/); + await expect(page.locator('.post-inspector')).toHaveClass(/stream-inspector/); + await expect(page.locator('.post-navigation')).toContainText('YouTube'); + await expect(page.locator('.post-content-overview')).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('post-detail-overview-1440.png'), + fullPage: true + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('button', { name: 'Inhalte', exact: true }).click(); + await expect(page.locator('.post-navigation')).toBeVisible(); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('post-mobile-navigation.png'), + fullPage: true + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.getByRole('link', { name: 'Postproduktion', exact: true }).first().click(); + await expect(page).toHaveURL(/\/post$/); + await completedShow.click(); + await expect(page.locator('.post-workspace')).toBeVisible(); + await page.goBack(); + await expect(page).toHaveURL(/\/post$/); + await completedShow.click(); + await expect(page.locator('.post-workspace')).toBeVisible(); + + await page.getByRole('button', { name: /Material & Quellen/ }).click(); + const materials = page.getByRole('dialog', { name: 'Aufzeichnungen & Kontextdateien' }); + await materials.getByText('Aufzeichnungsteil hinzufügen').click(); + await materials.getByLabel('Bezeichnung').fill('Teil 1'); + await materials.getByLabel('Mediendateipfad').fill('/media/heinzlive-teil-1.mov'); + await materials.getByLabel('Framerate').fill('25'); + await materials.getByLabel('Timebase').fill('25'); + await materials.getByRole('button', { name: 'Teil anlegen' }).click(); + + await page.getByRole('button', { name: /Material & Quellen/ }).click(); + await materials.locator('input[name="source"]').setInputFiles({ + name: 'heinzlive-kontext-teil-1.md', + mimeType: 'text/markdown', + buffer: Buffer.from('# Kontext\n\n[00:01:12] Linux ist keine Religion!') + }); + await materials.getByLabel('Aufzeichnungsteil').selectOption({ label: 'Teil 1' }); + await materials.getByRole('button', { name: 'Markdown importieren' }).click(); + + await page.getByRole('button', { name: 'YouTube anlegen' }).click(); + await expect(page.getByRole('heading', { name: 'Uncut-Video' })).toBeVisible(); + await page.getByLabel('Arbeitstitel').fill('Linux, Spiele und Netzkultur – Uncut'); + await page + .getByPlaceholder('Beschreibung, Kapitel, Links und Hashtags') + .fill('00:00 Begrüßung\n01:12 Linux ist keine Religion!\n\n#heinzlive'); + await page + .locator('.post-inspector') + .getByLabel('Aufzeichnungsteil') + .selectOption({ label: 'Teil 1' }); + await expect(page.locator('.post-inspector').getByLabel('Kontextdatei')).toHaveValue(/.+/); + await page.locator('.post-inspector select[name="status"]').selectOption('review'); + await page.getByRole('button', { name: 'Speichern', exact: true }).first().click(); + await expect(page.locator('.post-editor-header')).toContainText('Gespeichert'); + + const thumbnail = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ); + await page.locator('.post-thumbnail-upload').getByLabel('Bild').setInputFiles({ + name: 'uncut-thumbnail.png', + mimeType: 'image/png', + buffer: thumbnail + }); + await page + .locator('.post-thumbnail-upload') + .getByLabel('Alternativtext') + .fill('Fertiges Uncut-Thumbnail'); + await page.getByRole('button', { name: 'Thumbnail hochladen' }).click(); + await expect(page.locator('.post-thumbnail-grid img')).toBeVisible(); + const thumbnailDownload = page.getByRole('link', { name: 'Herunterladen' }); + const downloadResponse = await page.request.get( + (await thumbnailDownload.getAttribute('href')) as string + ); + expect(await downloadResponse.body()).toEqual(thumbnail); + + await page.getByRole('button', { name: 'Social-Clips anlegen' }).click(); + await page.getByLabel('Arbeitstitel').fill('Linux ist keine Religion!'); + await page + .getByPlaceholder('Nur das tatsächliche Originalmaterial eintragen') + .fill('Linux ist keine Religion!'); + await page + .locator('.post-inspector') + .getByLabel('Aufzeichnungsteil') + .selectOption({ label: 'Teil 1' }); + await expect(page.locator('.post-inspector').getByLabel('Kontextdatei')).toHaveValue(/.+/); + await page.locator('.post-inspector').getByLabel('Start (Sek.)').fill('72'); + await page.locator('.post-inspector').getByLabel('Ende (Sek.)').fill('103'); + await page.locator('.post-inspector').getByLabel('Cliptyp').selectOption('opinion'); + await page.getByRole('button', { name: 'Plattformtexte' }).click(); + await page.getByLabel('Öffentlicher Titel').fill('Linux ist keine Religion!'); + await page + .getByLabel('Gemeinsame Caption inklusive Hashtags') + .fill('Linux ist keine Religion!\n\n#linux #heinzlive'); + await page.getByRole('button', { name: 'Hooks & Endcard' }).click(); + await page.getByLabel('Primärer Hook').fill('Linux ist keine Religion!'); + await page.getByLabel('Schluss-Einblendung').fill('Mehr im vollständigen Stream.'); + await page.getByRole('button', { name: 'Schnitt' }).click(); + await page + .getByLabel('SRT-Untertitel') + .fill('1\n00:00:00,000 --> 00:00:02,000\nLinux ist keine Religion!'); + await page.getByRole('button', { name: 'Speichern', exact: true }).first().click(); + await expect(page.locator('.post-inspector')).toContainText('31 Sekunden'); + await expect(page.getByRole('link', { name: 'DaVinci Resolve XML' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'SRT-Untertitel' })).toBeVisible(); + const xmlHref = await page + .getByRole('link', { name: 'DaVinci Resolve XML' }) + .getAttribute('href'); + const xml = await page.request.get(xmlHref as string); + expect(await xml.text()).toContain(''); + const srtHref = await page.getByRole('link', { name: 'SRT-Untertitel' }).getAttribute('href'); + expect(await (await page.request.get(srtHref as string)).text()).toContain( + 'Linux ist keine Religion!' + ); + await page.screenshot({ path: testInfo.outputPath('post-desktop-clip.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('button', { name: 'Details', exact: true }).click(); + await expect(page.locator('.post-inspector')).toBeVisible(); + await expectNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath('post-mobile-details.png'), fullPage: true }); +}); + test('invitations, direct authorization, locking and password reset work end to end', async ({ page, browser @@ -626,9 +757,10 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn 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(); + const listBox = await page.locator('.fundus-library-content').boundingBox(); expect(navigationBox?.x).toBeLessThan(listBox?.x ?? 0); - await expect(page.locator('.fundus-context-pane')).toHaveCount(0); + await expect(page.locator('.content-quick-inspector')).toBeVisible(); + await expect(page.locator('.content-quick-inspector')).toHaveClass(/stream-inspector/); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); @@ -653,7 +785,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn .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\?includePlanned=1&selected=piece-console$/); + await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await expect(page).toHaveURL(/\/fundus\/piece-console\?includePlanned=1$/); + await page.keyboard.press('Escape'); + await expect(page).toHaveURL(/\/fundus\?includePlanned=1$/); + 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 page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); 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(); @@ -672,7 +814,7 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); - await page.getByLabel('Status').selectOption('preparing'); + await page.locator('.filter-picker').getByLabel('Status').selectOption('preparing'); await page.getByRole('button', { name: 'Filter anwenden' }).click(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible(); await page @@ -688,8 +830,20 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await page.locator('.app-header .account-menu > summary').click(); await expect(page.getByRole('button', { name: 'Hell' })).toHaveAttribute('aria-pressed', 'true'); + await page.locator('.app-header .account-menu > summary').click(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true }); + for (const [width, height] of [ + [960, 900], + [390, 844] + ] as const) { + await page.setViewportSize({ width, height }); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath(`fundus-overview-${width}.png`), + fullPage: true + }); + } }); test('sent and planned source filters stay independent and removing the last usage offers the piece again', async ({ @@ -864,25 +1018,11 @@ test('preparation opens and updates the concrete scheduled usage', async ({ page await preparation.getByRole('link', { name: /Die kleine Konsole/ }).click(); await expect(page).toHaveURL(/usage=usage-console/); - const editor = page.getByRole('dialog', { - name: 'Die kleine Konsole mit dem großen Dreh' - }); + const editor = page.locator('.stream-inspector'); await expect(editor).toBeVisible(); - await editor.getByRole('button', { name: 'Präsentation', exact: true }).click(); - await expect(editor.getByRole('radio', { name: 'Text', exact: true })).toBeChecked(); - await editor.getByRole('radio', { name: 'Nur Bild', exact: true }).check(); - await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toHaveCount(0); - await editor.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); - await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toBeVisible(); - await expect(editor.getByLabel('Kartentitel')).toBeVisible(); - await editor.getByRole('radio', { name: 'Video', exact: true }).check(); - 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: 'Streamtext', exact: true }).click(); - await editor.getByText('Dauer und Einplanung', { exact: true }).click(); + await expect(editor).toContainText('Streamfassung'); await editor.getByLabel('Vorbereitungsstatus').selectOption('ready'); - await editor.getByRole('button', { name: 'Verwendung speichern' }).click(); + await editor.getByRole('button', { name: 'Speichern', exact: true }).click(); await page.goto('/sendungen'); await expect(page.locator('.preparation-overview')).toHaveCount(0); @@ -903,6 +1043,11 @@ test('desktop builder opens the piece library beside the wider run sheet on dema await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible(); await expect(page.locator('.source-pane')).toBeHidden(); await expect(page.locator('.run-pane')).toBeVisible(); + const streamInspector = page.locator('.stream-inspector'); + await expect( + streamInspector.getByRole('heading', { name: 'Streamdaten und Twitch-Texte' }) + ).toBeVisible(); + await expect(streamInspector.getByLabel('Twitch-Titel')).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeHidden(); await expect( page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' }) @@ -912,13 +1057,39 @@ test('desktop builder opens the piece library beside the wider run sheet on dema await expect(page.locator('.source-pane')).toBeVisible(); const sourceBox = await page.locator('.source-pane').boundingBox(); const runBox = await page.locator('.run-pane').boundingBox(); + const inspectorBox = await streamInspector.boundingBox(); expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0); expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0); + expect(runBox?.x ?? 9999).toBeLessThan(inspectorBox?.x ?? 0); + expect(inspectorBox?.width ?? 0).toBeGreaterThanOrEqual(288); + expect(inspectorBox?.width ?? 9999).toBeLessThanOrEqual(352); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-desktop-dark.png'), fullPage: true }); + + await page.locator('.section-drag-handle').first().click(); + await expect(streamInspector).toContainText('Abschnitt'); + await expect(streamInspector.getByLabel('Kategorie')).toBeVisible(); + await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); + + const firstUsage = page.locator('.editable-usage').first(); + await firstUsage.getByRole('button', { name: /bearbeiten und verschieben/ }).click(); + await expect(streamInspector).toContainText('Streamfassung'); + await expect(streamInspector.getByLabel('Dauer in Minuten')).toBeVisible(); + await expect(streamInspector.getByRole('link', { name: 'Piece bearbeiten' })).toBeVisible(); + const usageFooterBox = await streamInspector.locator('.stream-inspector-actions').boundingBox(); + expect(usageFooterBox?.height).toBeLessThan(150); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('stream-usage-editor-desktop-dark.png'), + fullPage: true + }); + await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); + await expect( + streamInspector.getByRole('heading', { name: 'Streamdaten und Twitch-Texte' }) + ).toBeVisible(); }); test('narrow desktop retains the horizontal navigation and switches the builder panes', async ({ @@ -1036,18 +1207,16 @@ 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('Streamfassung'); - await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible(); + const streamInspector = page.locator('.stream-inspector'); + await expect(streamInspector).toContainText('Streamfassung'); + await expect(streamInspector.locator('.stream-inspector-form')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('verwendungseditor-desktop.png'), fullPage: true }); - await page - .locator('dialog[open]') - .getByRole('button', { name: 'Aus Piece aktualisieren' }) - .click(); - await expect(page.locator('dialog[open]')).toHaveCount(0); + await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); + await streamInspector.getByRole('button', { name: 'Aus Piece aktualisieren' }).click(); usages = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) @@ -1058,19 +1227,13 @@ 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 editInlineTitle( - usages.nth(0), - 'Die kleine Konsole mit dem großen Dreh', - 'Konsole als Auftakt' - ); - await usages.nth(0).locator('.piece-meta-details > summary').click(); - await usages.nth(0).getByLabel('Dauer in Minuten').fill('5'); - await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click(); - await usages - .nth(0) - .getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true }) + let usageDialog = page.locator('.stream-inspector'); + await usageDialog.getByLabel('Interner Titel').fill('Konsole als Auftakt'); + await usageDialog.getByLabel('Dauer in Minuten').fill('5'); + await usageDialog + .getByRole('textbox', { name: 'Eigene Notizen', exact: true }) .fill('Kurbel zuerst zeigen'); - await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click(); + await usageDialog.getByRole('button', { name: 'Speichern', exact: true }).click(); await expect(reactionSection).toContainText('Konsole als Auftakt'); usages = page @@ -1083,19 +1246,13 @@ 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 editInlineTitle( - usages.nth(1), - 'Die kleine Konsole mit dem großen Dreh', - 'Konsole als Rückblick' - ); - await usages.nth(1).locator('.piece-meta-details > summary').click(); - await usages.nth(1).getByLabel('Dauer in Minuten').fill('15'); - await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click(); - await usages - .nth(1) - .getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true }) + usageDialog = page.locator('.stream-inspector'); + await usageDialog.getByLabel('Interner Titel').fill('Konsole als Rückblick'); + await usageDialog.getByLabel('Dauer in Minuten').fill('15'); + await usageDialog + .getByRole('textbox', { name: 'Eigene Notizen', exact: true }) .fill('Andere Perspektive'); - await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click(); + await usageDialog.getByRole('button', { name: 'Speichern', exact: true }).click(); await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.'); usages = page @@ -1106,7 +1263,8 @@ test('a show can be planned from a template with independent usages and reserve' .nth(1) .getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' }) .click(); - await usages.nth(1).getByRole('button', { name: 'Duplizieren' }).click(); + await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); + await streamInspector.getByRole('button', { name: 'Duplizieren' }).click(); await expect(reactionSection.locator('.editable-usage')).toHaveCount(3); await expect(reactionSection.locator('.editable-usage').nth(2)).toContainText( 'Konsole als Rückblick' @@ -1114,6 +1272,7 @@ test('a show can be planned from a template with independent usages and reserve' await expect(page.locator('.show-summary')).toContainText('2 Std. 40 Min.'); const planTimeBeforeReserve = await page.locator('.show-summary').textContent(); + await page.getByRole('button', { name: 'Pieces auswählen' }).click(); await source.getByLabel(/Ziel für/).selectOption(''); await source.getByRole('button', { name: 'Hinzufügen' }).click(); await expect(page.locator('.reserve-usage')).toHaveCount(1); @@ -1124,11 +1283,10 @@ 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('Streamfassung'); - await expect( - page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' }) - ).toBeVisible(); - await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); + await expect(streamInspector).toContainText('Streamfassung'); + await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); + await expect(streamInspector.getByRole('button', { name: 'Duplizieren' })).toBeVisible(); + await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }).first(); await moveUp.focus(); @@ -1161,8 +1319,10 @@ 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('input[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen'); - await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); + await expect(page.locator('.stream-inspector [name="ownNotes"]')).toHaveValue( + 'Kurbel zuerst zeigen' + ); + await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); const duplicatedUsage = page .locator('.editable-usage') .filter({ hasText: 'Konsole als Rückblick' }); @@ -1171,35 +1331,32 @@ test('a show can be planned from a template with independent usages and reserve' .nth(1) .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('input[name="ownNotes"]')).toHaveValue( + await expect(streamInspector.getByLabel('Dauer in Minuten')).toHaveValue('15'); + await expect(page.locator('.stream-inspector [name="ownNotes"]')).toHaveValue( 'Andere Perspektive' ); - await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); + await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); await page.setViewportSize({ width: 390, height: 844 }); await savedUsage .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .click(); - await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible(); + await expect(streamInspector.locator('.stream-inspector-form')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('verwendungseditor-mobile.png'), fullPage: true }); - await page - .locator('dialog[open]') + await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); + await streamInspector .getByRole('button', { name: 'Aus Stream entfernen' }) .scrollIntoViewIfNeeded(); - await expect( - page.locator('dialog[open]').getByRole('button', { name: 'Aus Stream entfernen' }) - ).toBeVisible(); + await expect(streamInspector.getByRole('button', { name: 'Aus Stream entfernen' })).toBeVisible(); await page - .locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]') + .locator('.stream-inspector form[action="?/updateUsageMetadata"] input[name="revision"]') .evaluate((element) => ((element as HTMLInputElement).value = '1')); - await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click(); + await streamInspector.getByRole('button', { name: 'Speichern', exact: true }).click(); await expect(page.getByRole('alert')).toContainText('inzwischen geändert'); - await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.getByRole('button', { name: 'Ablauf', exact: true }).click(); await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel'); await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click(); @@ -1209,7 +1366,9 @@ test('a show can be planned from a template with independent usages and reserve' await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); await expect( - page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) + page + .locator('.piece-card') + .getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) ).toBeVisible(); await page.goto(showUrl); }); @@ -1271,13 +1430,22 @@ test('long titles and longer fundus lists reflow without overflow', async ({ pag }); }); -test('inbox, archive and administration stay concise', async ({ page }) => { - for (const width of [390, 1280]) { - await page.setViewportSize({ width, height: 900 }); +test('inbox, archive and administration stay concise', async ({ page }, testInfo) => { + for (const [width, height] of [ + [390, 844], + [960, 900], + [1440, 1000] + ] as const) { + await page.setViewportSize({ width, height }); await page.goto('/eingang'); await expect(page.locator('.capture-panel')).toBeVisible(); - await expect(page.locator('.inbox-item')).toHaveCount(1); + await expect(page.locator('.inbox-catalog-row')).toHaveCount(1); + await expect(page.locator('.content-quick-inspector')).toBeVisible(); await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath(`inbox-overview-${width}.png`), + fullPage: true + }); await page.goto('/archiv'); await expect(page.locator('.archive-list article')).toHaveCount(1); await expectNoHorizontalOverflow(page); @@ -1331,7 +1499,7 @@ test('rss selection and integration catalogs keep deliberate choices', async ({ await page.goto('/eingang'); const rssPanel = page.locator('.rss-inbox-panel'); await expect(rssPanel).toContainText('45 Beiträge sind verfügbar'); - await rssPanel.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click(); + await page.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click(); const rssDialog = page.getByRole('dialog', { name: 'RSS-Beiträge auswählen' }); await expect(rssDialog).toBeVisible(); await expect(rssDialog.getByRole('option')).toHaveCount(20); @@ -1367,7 +1535,7 @@ test('rss selection and integration catalogs keep deliberate choices', async ({ await expect(page.getByRole('status')).toContainText('2 Beiträge in den Eingang übernommen'); await expect(rssPanel).toContainText('43 Beiträge sind verfügbar'); await expect(page.getByText('E2E Quelle A Beitrag 25', { exact: true })).toBeVisible(); - await rssPanel.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click(); + await page.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click(); await expect(rssDialog.getByText('E2E Quelle A Beitrag 25', { exact: true })).toHaveCount(0); await expect(rssDialog.getByText('E2E Quelle A Beitrag 1', { exact: true })).toHaveCount(0); await rssDialog.getByRole('button', { name: 'RSS-Auswahl schließen' }).click(); @@ -1460,13 +1628,15 @@ 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 openEditorialField(page, 'Private Recherche'); 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(); await toolChoice.check(); await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board'); await page.getByLabel('Notiz').fill('Vor der Sendung sortieren'); - await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); + await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen'); + await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); const toolsSection = page.locator('.piece-source-editor').filter({ hasText: 'Werkzeuge' }); await toolsSection.scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath('piece-tools-desktop.png') }); @@ -1491,6 +1661,7 @@ 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 openEditorialField(page, 'Private Recherche'); 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( @@ -1505,500 +1676,97 @@ test('tools stay editable in pieces after catalog deactivation', async ({ page } await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png') }); }); -test('validated YouTube and TikTok players switch without exposing private fields', async ({ - page -}, testInfo) => { - addPresentationFixture(); - try { - await page.context().route('https://www.youtube-nocookie.com/**', (route) => - route.fulfill({ - contentType: 'text/html', - body: 'YouTube Test' - }) - ); - await page.context().route('https://www.tiktok.com/player/**', (route) => - route.fulfill({ - contentType: 'text/html', - body: 'TikTok Test' - }) - ); - await page.goto('/live?show=e2e-presentation'); - 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 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/); - - await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click(); - const youtube = output.locator('iframe[title="YouTube Vorschau"]'); - await expect(youtube).toHaveAttribute( - 'src', - 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75' - ); - await page.getByRole('button', { name: 'Präsentation leeren' }).click(); - await expect(youtube).toHaveCount(0); - await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); - await expect(output.locator('.output-connection')).toHaveClass(/sr-only/); - const publicState = await output.evaluate(async () => - JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json())) - ); - expect(publicState).not.toContain('PRIVATE_RESEARCH'); - expect(publicState).not.toContain('PRIVATE_MODERATION'); - - await page.getByRole('button', { name: 'TikTok Hochformat präsentieren' }).click(); - await expect(youtube).toHaveCount(0); - await expect(output.locator('iframe[title="TikTok Vorschau"]')).toHaveAttribute( - 'src', - 'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1' - ); - await expect(output.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute( - 'href', - 'https://www.tiktok.com/@scout2015/video/6718335390845095173' - ); - await output.setViewportSize({ width: 390, height: 844 }); - await expectNoHorizontalOverflow(output); - await output.screenshot({ - path: testInfo.outputPath('heinzlive-video-mobile.png'), - fullPage: true - }); - await output.close(); - } finally { - removePresentationFixture(); - } -}); - -test('live control persists state and exposes only explicit presentation data', async ({ - page, - browser -}, testInfo) => { - setSectionDuration('section-stories', 300); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); - await page - .getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true }) - .click(); - const png = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', - 'base64' - ); - await page.getByLabel('Bild', { exact: true }).setInputFiles({ - name: 'live-konsole.png', - mimeType: 'image/png', - buffer: png - }); - await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); - await page.getByRole('button', { name: 'Bild hochladen' }).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 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 }); - await page.goto('/sendungen/show-october'); - const plannedConsole = page - .locator('.editable-usage') - .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 Piece aktualisieren' }).click(); +test('live is a read-only Stream projection with local selection', async ({ page }, testInfo) => { + const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; + if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); + const databasePath = path.join(dataDirectory, 'e2e.sqlite'); + const state = () => { + const database = new Database(databasePath, { readonly: true }); + const show = database + .prepare('SELECT status, revision FROM shows WHERE id = ?') + .get('show-october'); + const usages = database + .prepare( + `SELECT u.id, u.status, u.position, u.show_section_id AS sectionId + FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE s.show_id = ? ORDER BY s.position, u.position` + ) + .all('show-october'); + database.close(); + return JSON.stringify({ show, usages }); + }; + const before = state(); + await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/live?show=show-october'); - 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: '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}/); - const liveMenuTrigger = page.getByRole('button', { name: 'Menü', exact: true }); - await liveMenuTrigger.click(); - const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' }); - await expect(liveMenu).toBeVisible(); - await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten'); - await expect(liveMenu).toContainText('Läuft'); - await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0); - await expect(liveMenu.getByRole('button', { name: 'Pausieren', exact: true })).toHaveCount(0); - await expect(liveMenu.getByRole('button', { name: 'Fortsetzen', exact: true })).toHaveCount(0); - await expect(liveMenu.getByText(/zurücksetzen/)).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(liveMenu).toBeHidden(); - await expect(liveMenuTrigger).toBeFocused(); - await page.getByRole('button', { name: 'Stream pausieren' }).click(); - await expect(page.getByRole('button', { name: 'Stream fortsetzen' })).toBeVisible(); - await liveMenuTrigger.click(); - await expect(liveMenu).toContainText('Pausiert'); - await expect(liveMenu.getByRole('button', { name: 'Fortsetzen', exact: true })).toHaveCount(0); - await page.keyboard.press('Escape'); - await page.getByRole('button', { name: 'Stream fortsetzen' }).click(); - await expect(page.getByRole('button', { name: 'Stream pausieren' })).toBeVisible(); - await liveMenuTrigger.press('Enter'); - await expect(liveMenu).toBeVisible(); - await page.locator('.live-current').click({ position: { x: 8, y: 8 } }); - await expect(liveMenu).toBeHidden(); - await expect(liveMenuTrigger).toBeFocused(); - await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); - await expect(page.locator('.live-output-state')).toContainText('Leer'); - await page.getByText('Quellen · 1', { exact: true }).click(); - await expect(page.locator('.live-materials')).toContainText('Herstellerseite'); + await expect(page.locator('.app-header')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible(); + await expect(page.locator('.live-readonly-state')).toContainText('nur lesen'); + await expect(page.locator('.live-view-page form')).toHaveCount(1); await expect( - page.getByText('Herstellerangaben gegen unabhängige Tests prüfen') - ).not.toBeVisible(); + page.getByRole('button', { name: /Stream starten|Erledigen|Überspringen|Pausieren|Sortieren/ }) + ).toHaveCount(0); - const before = await page - .locator('.live-current') - .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); - await page.getByText('Menü', { exact: true }).click(); - await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); - await page.reload(); - const restored = await page - .locator('.live-current') - .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); - expect(restored).not.toBe(before); - await page.locator('.research-details > summary').click(); - await expect(page.locator('.research-details')).toHaveAttribute('open', ''); - - await page.getByText('Menü', { exact: true }).click(); - await page.getByText('Ausgabe einrichten', { exact: true }).click(); - const [popup] = await Promise.all([ - page.waitForEvent('popup'), - page.getByRole('button', { name: 'Mit diesem Stream verbinden und öffnen' }).click() - ]); - await popup.waitForURL(/\/heinzlive\/default#/); - const outputUrl = popup.url(); - const anonymous = await browser.newContext(); - const output = await anonymous.newPage(); - await output.goto(outputUrl); - await expect(output.locator('.output-content')).toHaveCount(0); - await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); - - await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); - await expect( - output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) - ).toBeVisible({ - timeout: 10_000 - }); - await expect(output.getByRole('heading', { name: 'Weniger Leistung, mehr Idee' })).toBeVisible(); - await expect(output.locator('.output-topics')).toHaveText('Gaming'); - await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible(); - await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true }); - const outputHtml = await output.content(); - expect(outputHtml).not.toContain('Herstellerangaben'); - expect(outputHtml).not.toContain('Erst Formfaktor zeigen'); - expect(outputHtml).not.toContain(adminEmail); - expect(outputHtml).not.toContain('live-konsole.png'); - expect(outputHtml).not.toContain('https://example.com/console'); - const stateUrl = new URL(outputUrl); - stateUrl.hash = ''; - stateUrl.pathname += '/state'; - const outputState = await anonymous.request.get(stateUrl.toString()); - expect(outputState.status()).toBe(200); - const outputJson = await outputState.text(); - expect(outputJson).toContain('Weniger Leistung'); - expect(outputJson).not.toContain('Herstellerangaben'); - expect(outputJson).not.toContain('Moderationsstichpunkte'); - expect(outputJson).not.toContain('live-konsole.png'); - await page.getByRole('button', { name: 'Präsentation leeren' }).click(); - await expect(output.locator('.output-content')).toHaveCount(0); - await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); - await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); - - await page.getByText('Reserve', { exact: true }).click(); - await page.getByRole('button', { name: 'Als Nächstes' }).click(); - await expect(page.locator('.live-next-inline')).toContainText('Pixelart'); - await page.getByRole('button', { name: 'Erledigen & weiter' }).click(); - await expect(page.locator('.live-current')).toContainText('Pixelart'); - await page.getByRole('button', { name: 'Überspringen' }).click(); - await expect(page.locator('#current-title')).toHaveText( + const usageButtons = page.locator('.live-view-usage'); + await expect(usageButtons).toHaveCount(3); + const second = usageButtons.nth(1); + await second.click(); + await expect(page.locator('#live-piece-title')).toHaveText( 'Warum praktische Effekte wieder auffallen' ); - await expect( - page.locator('.live-run-item').filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) - ).toContainText('Erledigt'); - await expect( - page.locator('.live-run-item').filter({ hasText: 'Pixelart, die nicht nostalgisch sein will' }) - ).toContainText('Übersprungen'); - await expect( - page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) - ).toContainText('Aktuell'); - await page.locator('.live-current-scroll').evaluate((element) => { - for (let index = 0; index < 12; index += 1) { - const paragraph = document.createElement('p'); - paragraph.textContent = 'Temporärer Langtext für die Lesepositionsprüfung.'; - element.append(paragraph); - } - element.scrollTop = element.scrollHeight; - element.dispatchEvent(new Event('scroll')); - }); - expect( - await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop) - ).toBeGreaterThan(0); - - const countdownBeforeBack = await page.locator('.section-countdown strong').textContent(); - await page.getByRole('button', { name: 'Zurück', exact: true }).click(); - await expect(page.locator('#current-title')).toHaveText( - 'Pixelart, die nicht nostalgisch sein will' - ); - await expect( - page - .locator('.live-run-item.current') - .filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) - ).toBeVisible(); - await expect( - page.locator('.live-action-bar').getByRole('button', { name: 'Wiederaufnehmen', exact: true }) - ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Überspringen' })).toHaveCount(0); - await expect(page.getByRole('button', { name: /Erledigen/ })).toHaveCount(0); - await expect( - output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) - ).toBeVisible(); - const countdownAfterBack = await page.locator('.section-countdown strong').textContent(); - expect(countdownAfterBack).not.toBe('05:00'); - expect(countdownAfterBack?.slice(0, 3)).toBe(countdownBeforeBack?.slice(0, 3)); - - await page - .getByRole('button', { - name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen' - }) - .click(); - await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); - await expect - .poll(() => page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)) - .toBe(0); - await expect( - page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) - ).toContainText('Aktuell'); - await expect( - output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) - ).toBeVisible(); - await page - .getByRole('button', { - name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen' - }) - .click(); - await expect(page.locator('#current-title')).toHaveText( - 'Warum praktische Effekte wieder auffallen' - ); - await expect(output.getByText('Wenn das Licht wirklich auf ein Modell fällt.')).toHaveCount(0); - - await page.setViewportSize({ width: 1440, height: 900 }); - await setTheme(page, 'dark'); - await page.reload(); - await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); - await expectNoHorizontalOverflow(page); - await page.screenshot({ path: testInfo.outputPath('live-controller-desktop-dark.png') }); - - await page.setViewportSize({ width: 900, height: 450 }); - await setTheme(page, 'light'); - await page.reload(); - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); - await page.evaluate(() => { - const title = document.querySelector('#current-title'); - if (title) - title.textContent = - 'Warum praktische Effekte in einer außergewöhnlich langen Live-Moderation wieder auffallen'; - const copy = document.querySelector('.prompter-copy'); - if (copy) { - for (let index = 0; index < 8; index += 1) { - const paragraph = document.createElement('p'); - paragraph.textContent = - 'Zusätzlicher langer Moderationstext prüft die unabhängige Scrollfläche ohne die feste Bedienleiste zu verdrängen.'; - copy.append(paragraph); - } - } - const rail = document.querySelector('.live-run-scroll'); - const section = rail?.querySelector('.rail-section'); - if (rail && section) { - for (let index = 0; index < 8; index += 1) rail.append(section.cloneNode(true)); - } - }); - const compactLayout = await page.evaluate(() => { - const current = document.querySelector('.live-current')?.getBoundingClientRect(); - const rail = document.querySelector('.live-rail')?.getBoundingClientRect(); - const footer = document.querySelector('.live-action-bar')?.getBoundingClientRect(); - const pageStyle = getComputedStyle(document.querySelector('.live-page') as HTMLElement); - const pieceScroll = document.querySelector('.live-current-scroll'); - const railScroll = document.querySelector('.live-run-scroll'); + await expect(second).toHaveAttribute('aria-pressed', 'true'); + const selectedStyle = await second.evaluate((element) => { + const style = getComputedStyle(element); return { - pageFits: document.documentElement.scrollHeight <= innerHeight, - twoColumns: Boolean(current && rail && rail.x > current.x + current.width - 1), - footerVisible: Boolean(footer && footer.bottom <= innerHeight && footer.top >= 0), - shortViewportDensity: parseFloat(pageStyle.gap) < 8, - unscaled: pageStyle.transform === 'none', - unzoomed: ['', '1', 'normal'].includes(pageStyle.getPropertyValue('zoom')), - pieceScrolls: Boolean(pieceScroll && pieceScroll.scrollHeight > pieceScroll.clientHeight), - railScrolls: Boolean(railScroll && railScroll.scrollHeight > railScroll.clientHeight) + radius: Number.parseFloat(style.borderRadius), + border: style.borderColor, + background: style.backgroundColor, + shadow: style.boxShadow }; }); - expect(compactLayout).toEqual({ - pageFits: true, - twoColumns: true, - footerVisible: true, - shortViewportDensity: true, - unscaled: true, - unzoomed: true, - pieceScrolls: true, - railScrolls: true - }); - await page.screenshot({ path: testInfo.outputPath('live-controller-compact-desktop.png') }); + expect(selectedStyle.radius).toBeGreaterThan(0); + expect(selectedStyle.border).not.toBe('rgba(0, 0, 0, 0)'); + expect(selectedStyle.background).not.toBe('rgba(0, 0, 0, 0)'); + expect(selectedStyle.shadow).toBe('none'); + expect(state()).toBe(before); + await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); + expect(state()).toBe(before); - await page.setViewportSize({ width: 670, height: 450 }); - await page.evaluate(() => { - const section = document.querySelector('.live-context-title.section span'); - const next = document.querySelector('.live-context-title.next span'); - if (section) - section.textContent = - 'Aktuelles Segment mit einem außergewöhnlich langen Namen für die kompakte Regie'; - if (next) - next.textContent = - 'Danach folgt ein Piece mit einem ebenfalls außergewöhnlich langen und vollständigen Titel'; + const desktopColumns = await page.evaluate(() => { + const rundown = document.querySelector('.live-view-rundown')?.getBoundingClientRect(); + const content = document.querySelector('.live-view-content')?.getBoundingClientRect(); + const details = document.querySelector('.live-view-details')?.getBoundingClientRect(); + return Boolean(rundown && content && details && content.x > rundown.x && details.x > content.x); }); - const smallHeader = page.locator('.live-command-header'); - const smallHeaderBox = await smallHeader.boundingBox(); - expect(smallHeaderBox?.height ?? 999).toBeLessThan(105); - await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'nowrap'); - await expect(page.locator('.live-context-title.next span')).toHaveCSS('white-space', 'nowrap'); - await page.locator('.live-context-title.section').click(); - await expect(page.locator('.live-context-title.section')).toHaveClass(/expanded/); - await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'normal'); - await page.keyboard.press('Escape'); - await expect(page.locator('.live-context-title.section')).not.toHaveClass(/expanded/); - await page.locator('.live-context-title.next').focus(); - await page.locator('.live-context-title.next').press('Enter'); - await expect(page.locator('.live-context-title.next')).toHaveClass(/expanded/); - await page.keyboard.press('Escape'); - await liveMenuTrigger.click(); - await expect(liveMenu).toBeVisible(); - const compactMenuBox = await liveMenu.boundingBox(); - expect(compactMenuBox?.x ?? -1).toBeGreaterThanOrEqual(0); - expect(compactMenuBox?.y ?? -1).toBeGreaterThanOrEqual(0); - expect( - Math.ceil((compactMenuBox?.x ?? 999) + (compactMenuBox?.width ?? 999)) - ).toBeLessThanOrEqual(670); - expect( - Math.ceil((compactMenuBox?.y ?? 999) + (compactMenuBox?.height ?? 999)) - ).toBeLessThanOrEqual(450); - await liveMenu.getByRole('link', { name: /Zur Planung/ }).scrollIntoViewIfNeeded(); - await expect(liveMenu.getByRole('link', { name: /Zur Planung/ })).toBeVisible(); - await liveMenu.getByRole('button', { name: 'Zeiten ausblenden' }).click(); - await expect(page.locator('.live-piece-time')).toHaveCount(0); - await liveMenu.getByRole('button', { name: 'Zeiten einblenden' }).click(); - await expect(page.locator('.live-piece-time')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(liveMenuTrigger).toBeFocused(); + expect(desktopColumns).toBe(true); await expectNoHorizontalOverflow(page); - await page.screenshot({ path: testInfo.outputPath('live-controller-670x450.png') }); + await page.screenshot({ path: testInfo.outputPath('live-readonly-desktop.png'), fullPage: true }); - const countdownBeforeReload = await page.locator('.section-countdown strong').textContent(); + const rejectedWrite = await page.request.post('/live?show=show-october&/start', { + form: { showId: 'show-october', revision: '0', commandId: 'e2e-forbidden' } + }); + expect(rejectedWrite.status()).toBeGreaterThanOrEqual(400); + expect(state()).toBe(before); + + await page.setViewportSize({ width: 960, height: 900 }); await page.reload(); - await expect(page.locator('#current-title')).toHaveText( - 'Warum praktische Effekte wieder auffallen' - ); - const countdownAfterReload = await page.locator('.section-countdown strong').textContent(); - expect(countdownAfterReload).not.toBe('05:00'); - expect(countdownAfterReload).toMatch(/0[34]:\d{2}/); - expect(countdownBeforeReload).toMatch(/0[34]:\d{2}/); - - await page.setViewportSize({ width: 390, height: 700 }); - const liveRail = page.locator('.live-rail'); - const railClose = liveRail.getByRole('button', { name: 'Ablauf schließen' }); - await expect(liveRail).toBeHidden(); - await expect(page.locator('.live-action-bar')).toBeVisible(); + await expect(page.locator('.live-view-switch')).toBeHidden(); + await expect(page.locator('.live-view-rundown')).toBeVisible(); + await expect(page.locator('.live-view-content')).toBeVisible(); + await expect(page.locator('.live-view-details')).toBeVisible(); await expectNoHorizontalOverflow(page); - expect( - await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight) - ).toBe(true); - await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') }); - await liveMenuTrigger.click(); - const mobileMenuBox = await liveMenu.boundingBox(); - expect(Math.ceil((mobileMenuBox?.x ?? 999) + (mobileMenuBox?.width ?? 999))).toBeLessThanOrEqual( - 390 - ); - expect(Math.ceil((mobileMenuBox?.y ?? 999) + (mobileMenuBox?.height ?? 999))).toBeLessThanOrEqual( - 700 - ); - await page.keyboard.press('Escape'); - await expect(liveMenuTrigger).toBeFocused(); - await page.getByRole('button', { name: /Ablauf ·/ }).click(); - await expect(liveRail).toHaveClass(/open/); - await expect(railClose).toBeFocused(); - await expect - .poll(async () => { - const box = await liveRail.boundingBox(); - return box ? Math.ceil(box.x + box.width) : Number.POSITIVE_INFINITY; - }) - .toBeLessThanOrEqual(390); - const mobileRailBox = await liveRail.boundingBox(); - expect(mobileRailBox?.width ?? 0).toBeGreaterThan(340); - await expect(page.locator('.live-run-item.current')).toBeInViewport(); - await page.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') }); - await page.keyboard.press('Escape'); - await expect(liveRail).toBeHidden(); - await expect(page.getByRole('button', { name: /Ablauf ·/ })).toBeFocused(); - await page.getByRole('button', { name: /Ablauf ·/ }).click(); - await page.setViewportSize({ width: 900, height: 450 }); - await expect(liveRail).not.toHaveAttribute('role', 'dialog'); - await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', ''); - await page.setViewportSize({ width: 390, height: 700 }); - await expect(liveRail).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('live-readonly-compact.png'), fullPage: true }); - await page.getByRole('button', { name: /Ablauf ·/ }).click(); - await liveRail - .getByRole('button', { - name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen' - }) - .click(); - await expect(liveRail).toBeHidden(); - await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); - await page.getByRole('button', { name: /Ablauf ·/ }).click(); - await liveRail - .getByRole('button', { - name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen' - }) - .click(); - await expect(liveRail).toBeHidden(); - await expect(page.locator('#current-title')).toHaveText( - 'Warum praktische Effekte wieder auffallen' - ); - - 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); - await expectNoHorizontalOverflow(page); - - await output.route('**/state', (route) => route.abort()); - await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 }); - await output.unroute('**/state'); - await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 }); - await page.getByRole('button', { name: 'Präsentation leeren' }).click(); - await expect(output.locator('.output-content')).toHaveCount(0); - await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); - await page.setViewportSize({ width: 1280, height: 900 }); - - await page.evaluate(() => { - window.open = () => null; - }); - await page.getByText('Menü', { exact: true }).click(); - await page.getByText('Ausgabe einrichten', { exact: true }).click(); - await page.getByRole('button', { name: 'Ausgabe öffnen' }).click(); - await expect( - page - .getByRole('status') - .filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' }) - ).toBeVisible(); - await page.getByText('Menü', { exact: true }).click(); - page.once('dialog', (dialog) => dialog.accept()); - await page.getByRole('button', { name: 'Stream beenden' }).click(); - await page.getByText('Menü', { exact: true }).click(); - await expect(page.getByRole('dialog', { name: 'Regiemenü' })).toContainText('Lauf beendet'); - await expect(page.getByRole('button', { name: 'Beendeten Lauf wiederaufnehmen' })).toBeVisible(); - await anonymous.close(); - await popup.close(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator('.mobile-navigation')).toBeVisible(); + await page.getByRole('button', { name: 'Details', exact: true }).click(); + await expect(page.locator('.live-view-details')).toBeVisible(); + await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); + await page.getByRole('button', { name: 'Inhalt', exact: true }).click(); + await expect(page.locator('.live-view-content')).toBeVisible(); await expectNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath('live-readonly-mobile.png'), fullPage: true }); + expect(state()).toBe(before); }); test('completion, archive and protected guest sharing work end to end', async ({ @@ -2061,18 +1829,7 @@ test('completion, archive and protected guest sharing work end to end', async ({ await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible(); await planningGuest.close(); - await page.goto('/live?show=e2e-completion'); - await page.getByRole('button', { name: 'Menü', exact: true }).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'); - await expect(completion).toContainText('1 übersprungen'); - await expect(completion.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible(); - await expect(completion.getByText('Warum praktische Effekte wieder auffallen')).toHaveCount(0); - await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click(); - await expect(completion).toBeHidden(); - + finalizeCompletionFixture(); await page.goto('/sendungen?state=completed'); await expect( page.locator('.show-card-list').getByRole('link', { name: /E2E Abschluss/ }) @@ -2191,6 +1948,160 @@ test('completion, archive and protected guest sharing work end to end', async ({ } }); +test('piece writing room stays focused across content, themes and narrow screens', async ({ + page +}, testInfo) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await setTheme(page, 'light'); + await page.goto('/fundus/piece-console'); + const writing = page.locator('.piece-writing-stack'); + const context = page.locator('.piece-meta-details'); + await expect(writing).toBeVisible(); + await expect(context).toBeVisible(); + await expect(page.locator('.app-header .brand-version')).toHaveText('0.9.0'); + await expect(page.locator('.fundus-navigation')).toHaveCount(0); + const pieceDialog = page.getByRole('dialog', { name: /Die kleine Konsole/ }); + await expect(pieceDialog).toBeVisible(); + await pieceDialog.evaluate(async (element) => { + await Promise.all( + element.getAnimations({ subtree: true }).map((animation) => animation.finished) + ); + }); + const titleRowBox = await page.locator('.piece-editor-title-row').boundingBox(); + const headerActionsBox = await page.locator('.piece-editor-header-actions').boundingBox(); + expect(headerActionsBox?.y).toBeGreaterThanOrEqual( + (titleRowBox?.y ?? 0) + (titleRowBox?.height ?? 0) + ); + const workspaceBox = await page.locator('.piece-dialog-workspace').boundingBox(); + const writingBox = await writing.boundingBox(); + const contextBox = await context.boundingBox(); + const presentationBox = await page.locator('.piece-presentation-panel').boundingBox(); + expect((writingBox?.x ?? 0) - (workspaceBox?.x ?? 0)).toBeLessThanOrEqual(18); + expect( + (contextBox?.x ?? 0) - ((writingBox?.x ?? 0) + (writingBox?.width ?? 0)) + ).toBeLessThanOrEqual(18); + const firstWritingFieldBox = await page + .locator('.piece-writing-stack .rich-markdown-field') + .first() + .boundingBox(); + expect(firstWritingFieldBox?.height).toBeLessThan(170); + expect(contextBox?.height).toBeLessThan(500); + expect(presentationBox?.y).toBeLessThan(800); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('piece-editor-desktop-expanded-light.png'), + fullPage: true + }); + + await setTheme(page, 'dark'); + await page.reload(); + await expect(writing).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('piece-editor-desktop-overlay-dark.png'), + fullPage: true + }); + + await page.setViewportSize({ width: 960, height: 900 }); + const narrowDialogBox = await page.locator('.piece-editor-dialog').boundingBox(); + expect(narrowDialogBox?.width ?? 0).toBeGreaterThanOrEqual(850); + expect(narrowDialogBox?.width ?? 960).toBeLessThan(960); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('piece-editor-narrow-overlay-dark.png'), + fullPage: true + }); + + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('/fundus'); + await page.locator('.template-picker > summary').click(); + await page.getByRole('button', { name: /Leer starten/ }).click(); + await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); + await expect(page.getByRole('textbox', { name: 'Einleitung', exact: true })).toBeEmpty(); + await page.screenshot({ + path: testInfo.outputPath('piece-editor-empty-desktop-dark.png'), + fullPage: true + }); + const emptyPieceUrl = page.url(); + const emptyIntroField = page.locator('.rich-markdown-field:has(input[name="intro"])'); + const compactIntroBox = await emptyIntroField.boundingBox(); + await emptyIntroField + .getByRole('textbox', { name: 'Einleitung', exact: true }) + .fill(Array.from({ length: 8 }, (_, index) => `Absatz ${index + 1}`).join('\n\n')); + const expandedIntroBox = await emptyIntroField.boundingBox(); + expect(expandedIntroBox?.height).toBeGreaterThan((compactIntroBox?.height ?? 0) + 80); + const introEditor = emptyIntroField.getByRole('textbox', { name: 'Einleitung', exact: true }); + await introEditor.fill('Formatierter Einstieg'); + await introEditor.press('ControlOrMeta+a'); + await expect(emptyIntroField.getByRole('toolbar')).toBeVisible(); + await emptyIntroField.getByRole('button', { name: 'Fett' }).click(); + await expect(emptyIntroField.locator('input[name="intro"]')).toHaveValue( + '**Formatierter Einstieg**' + ); + await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); + const closeButton = page.getByRole('button', { name: 'Editor schließen' }); + await page.locator('dialog.piece-editor-dialog').evaluate((element) => { + sessionStorage.removeItem('piece-editor-close-animation'); + element.addEventListener('animationstart', (event) => { + if (event.animationName === 'surface-close') { + sessionStorage.setItem('piece-editor-close-animation', 'started'); + } + }); + element.addEventListener('animationend', (event) => { + if (event.animationName === 'surface-close') { + sessionStorage.setItem('piece-editor-close-animation', 'finished'); + } + }); + }); + await closeButton.click(); + await expect(page).toHaveURL(/\/fundus(?:\?|$)/); + expect(await page.evaluate(() => sessionStorage.getItem('piece-editor-close-animation'))).toBe( + 'finished' + ); + await page.goto('/fundus?includePlanned=1'); + 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 page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); + await expect(page).toHaveURL(/\/fundus\/piece-console/); + await page.getByRole('button', { name: 'Editor schließen' }).click(); + await page + .locator('.piece-card') + .filter({ hasText: 'Neues Piece' }) + .getByRole('link', { name: 'Neues Piece', exact: true }) + .click(); + await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); + await expect(page).toHaveURL( + new RegExp(`${emptyPieceUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\?.*)?$`) + ); + await expect(page.locator('input[name="intro"]')).toHaveValue('**Formatierter Einstieg**'); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + const navigation = page.getByRole('navigation', { name: 'Arbeitsbereich' }); + await navigation.getByRole('button', { name: 'Streamtext', exact: true }).click(); + await page.screenshot({ + path: testInfo.outputPath('piece-editor-mobile-streamtext-dark.png'), + fullPage: true + }); + await page.getByRole('button', { name: 'Einleitung formatieren' }).click(); + await expect( + page.locator('.rich-markdown-field:has(input[name="intro"])').getByRole('toolbar') + ).toBeVisible(); + await navigation.getByRole('button', { name: 'Details & Ausgabe' }).click(); + await expect(page.locator('#piece-editor-form').getByLabel('Status')).toBeVisible(); + await expect(page.getByRole('radio', { name: 'Text', exact: true })).toBeVisible(); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: testInfo.outputPath('piece-editor-mobile-dark.png'), + fullPage: true + }); + await navigation.getByRole('button', { name: 'Recherche & Quellen' }).click(); + await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible(); + await expect(page.locator('#piece-editor-form').getByLabel('Status')).not.toBeVisible(); +}); + test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ page, browser @@ -2212,7 +2123,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy await page.getByRole('button', { name: 'Editor schließen' }).click(); await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('link', { name: 'E2E Fundstück mit Markdown', exact: true }).click(); + await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await expect(page).toHaveURL(pieceUrl); + await openPieceMoreDetails(page); await page .getByLabel('Kurzbeschreibung') .fill('Persistiert nach Neuladen und ist gezielt suchbar.'); @@ -2231,28 +2144,32 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy 'Nur intern: **Hintergrund prüfen**.' ); await openEditorialField(page, 'Präsentationstext'); + await page.getByLabel('Öffentliche Überschrift').fill('Öffentlicher E2E-Titel'); await fillMarkdownSource( page, 'Öffentlicher Vorspann', '## Sichere Vorschau\n\n **sichtbar**' ); + await expect(page.locator('input[name="presentationText"]')).toHaveValue( + '## 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(); await source.getByLabel('Name').fill('Originalbeitrag'); await source.getByLabel('URL').fill('https://example.com/e2e-fundstueck'); await source.getByLabel('Zeitmarke in Sekunden').fill('42'); - await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); - await page.waitForTimeout(1_200); - await expect(page.locator('.save-indicator')).toContainText('Gespeichert'); + await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen'); + await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); + await page.reload(); await openEditorialField(page, 'Präsentationstext'); await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0); await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau'); - await page.reload(); await expect( page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' }) ).toBeVisible(); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); + await expect(page.getByLabel('Öffentliche Überschrift')).toHaveValue('Öffentlicher E2E-Titel'); await openEditorialField(page, 'Private Recherche'); await expect(page.locator('input[name="researchText"]')).toHaveValue( 'Nur intern: **Hintergrund prüfen**.' @@ -2284,6 +2201,7 @@ 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 openPieceMoreDetails(concurrent); await concurrent .getByLabel('Kurzbeschreibung') .fill('Diese veraltete Änderung darf nicht gewinnen.'); @@ -2302,9 +2220,12 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy await page.goto('/fundus?q=extern+aktualisiert'); await expect(page.locator('.piece-card')).toHaveCount(1); await expect( - page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) + page.locator('.piece-card').getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) ).toBeVisible(); await page.getByRole('link', { name: 'E2E Fundstück, extern aktualisiert', exact: true }).click(); + await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); + await page.locator('.piece-action-menu > summary').click(); + page.once('dialog', (dialog) => dialog.accept()); await page.getByRole('button', { name: 'Piece archivieren' }).click(); await expect(page).toHaveURL(/\/archiv$/); const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' }); @@ -2337,6 +2258,12 @@ 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('navigation', { name: 'Arbeitsbereich' }) + .getByRole('button', { + name: 'Recherche & Quellen' + }) + .click(); await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ @@ -2359,7 +2286,8 @@ test('a quickly captured video can be written and previewed before classificatio await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ'); 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.locator('.inbox-catalog-row').filter({ hasText: 'Schnelles Video-Fundstück' }).click(); + await page.getByRole('link', { name: 'Piece vollständig bearbeiten' }).click(); await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue( 'https://youtu.be/dQw4w9WgXcQ' ); @@ -2388,7 +2316,12 @@ 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('navigation', { name: 'Arbeitsbereich' }) + .getByRole('button', { name: 'Details & Ausgabe' }) + .click(); await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible(); + await openEditorialField(page, 'Private Recherche'); await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page); }); @@ -2402,7 +2335,8 @@ test('link preview uses one existing source, persists manual fields and isolates await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); await page.getByLabel('Vorschaubild-URL').fill(''); - await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); + await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen'); + await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); await page.reload(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked(); await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau'); @@ -2429,8 +2363,11 @@ test('link preview uses one existing source, persists manual fields and isolates }); await preview.close(); + await openEditorialField(page, 'Private Recherche'); await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private'); - await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); + await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen'); + await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); + await page.reload(); const blockedPreview = page.waitForResponse((response) => response.url().includes('?/loadPreview') );