3 Commits
Author SHA1 Message Date
hnzio a6c8606f3c release: add post-production workspace 0.9.0 2026-10-30 17:33:13 +01:00
hnzio abc31d920f Release 0.8.1 2026-10-27 19:22:31 +01:00
hnzio 07f2c4607b Release 0.8.0 2026-10-27 14:57:17 +01:00
69 changed files with 14659 additions and 3506 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.7.3", "version": "0.9.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+4 -4
View File
@@ -182,7 +182,7 @@ Zielressource erneut. MCP kann keine Inhalte schreiben.
### In ChatGPT einrichten ### In ChatGPT einrichten
1. Zuerst Version `0.7.3` unter der endgültigen öffentlichen HTTPS-Origin 1. Zuerst Version `0.8.1` unter der endgültigen öffentlichen HTTPS-Origin
bereitstellen. Lokalhost ohne öffentliches HTTPS ist für eine direkte bereitstellen. Lokalhost ohne öffentliches HTTPS ist für eine direkte
ChatGPT-Verbindung nicht erreichbar. ChatGPT-Verbindung nicht erreichbar.
2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und 2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und
@@ -260,8 +260,8 @@ Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben. Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Nach einem Update Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Seit Version `0.7.3`
auf `0.7.3` führt der bestehende Startprozess Migration `0020_oauth_mcp.sql` führt der bestehende Startprozess Migration `0020_oauth_mcp.sql`
aus. Anschließend muss die von Cloudron gelieferte HTTPS-Origin mit der in aus. Anschließend muss die von Cloudron gelieferte HTTPS-Origin mit der in
ChatGPT eingetragenen MCP-Domain übereinstimmen. Reverse Proxy und öffentliche ChatGPT eingetragenen MCP-Domain übereinstimmen. Reverse Proxy und öffentliche
Erreichbarkeit wurden ohne Cloudron-Zugang nicht geprüft. Erreichbarkeit wurden ohne Cloudron-Zugang nicht geprüft.
@@ -282,7 +282,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und `better-sqlite3`, `@node-rs/argon2`, Tiptap, Marked, sanitize-html, file-type und
Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
installierten Baum. installierten Baum.
+163
View File
@@ -1,5 +1,168 @@
# Abnahme # 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 |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Piece-Editor | Streamtext beginnt direkt unter dem Titel; Chromium prüft Autosave, Konflikt, Quellen, Werkzeuge, Medien und Präsentationsmodi | erfüllt am 27.10.2026 |
| Streamplanung | Ablauf ist Hauptfläche, Piece-Bibliothek öffnet bei Bedarf; Drag-and-drop, Klick-/Tastaturalternativen, Reserve und Usage-Editor laufen | erfüllt am 27.10.2026 |
| Live-Regie | Aktuelles Piece, Abschnitt und Moderation sind priorisiert; Timer, Ablauf-Sheet, Ausgabe und OBS-Aktionen bleiben erreichbar | erfüllt am 27.10.2026 |
| Globale Suche | Servertest prüft Typ-, Status- und Taxonomiefilter; Chromium prüft Filterspalte, Treffergruppen und mobilen Filterzugang | erfüllt am 27.10.2026 |
| Responsive Gestaltung | Gezielte Chromium-Sichtkontrolle bei 1440 × 1000 und 390 × 844 in Piece, Planung, Live und Suche ohne horizontalen Überlauf | erfüllt am 27.10.2026 |
| Verträge und Sicherheit | Navigation, URLs, Actions, Revisionen, Snapshots, Rechte und öffentliche Positivprojektionen bleiben unverändert | erfüllt am 27.10.2026 |
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 63 von 63 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 21 von 21 Chromium-Abläufen erfolgreich.
- `git diff --check`: erfolgreich.
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
## Redaktioneller Arbeitsbereich 0.8.0
| Bereich | Nachweis | Stand |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Gemeinsame Navigation | Chromium prüft Start, Eingang, Pieces, Streams und Live im Desktopkopf sowie als mobile Bottom-Navigation | erfüllt am 27.10.2026 |
| Dashboard ohne erfundene Daten | Root-Route zeigt nächsten Ablauf, aktuelle Pieces, Schnellaktionen und weitere Streams aus bestehenden Diensten | erfüllt am 27.10.2026 |
| Berechtigungssichere Suche | Servertests finden private Piece-Texte, archivierte Pieces und Stream-Snapshots; Browser prüft gruppierte Trefferseite | erfüllt am 27.10.2026 |
| Pieces und Abschluss | Chromium prüft automatisches Ausblenden, Abgeschlossen-Ansicht, verlinkte Historie und erneute Einplanung | erfüllt am 27.10.2026 |
| Streams und Historie | Filter leiten Angelegt, Vorbereitung, Sendefertig und Abgeschlossen ab; historische Abläufe bleiben unveränderlich | erfüllt am 27.10.2026 |
| Kontextnavigationen | Pieces und Streams besitzen lokale Suche und einklappbare Desktopnavigation; mobile Flächen bleiben überlauffrei | erfüllt am 27.10.2026 |
| Optionale rechte Arbeitsleiste | Usage-Auswahl öffnet bestehenden Editor nicht-modal rechts; Schließen, Speichern und mobile Vollbildansicht bleiben | erfüllt am 27.10.2026 |
| Daten- und Sicherheitsgrenzen | 62 Verhaltenstests sowie Browserabläufe für Rollen, Revisionen, Uploads, Live, Freigaben und Präsentation bleiben grün | erfüllt am 27.10.2026 |
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 62 von 62 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 21 von 21 Chromium-Abläufen erfolgreich.
- `git diff --check`: erfolgreich.
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
## Horizontale Fundus-Werkbank 0.7.4
| Bereich | Nachweis | Stand |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Horizontale Hauptnavigation | Chromium prüft Desktopkopf und getrennte mobile Navigation bei den vorhandenen Matrixbreiten | erfüllt am 27.10.2026 |
| Exakt drei Fundusspalten | Bounding-Box-Prüfung ordnet Navigation, Liste und Kontext bei 1440 Pixeln nebeneinander an | erfüllt am 27.10.2026 |
| Zustanderhaltende Navigation | Chromium behält `includePlanned` beim Wechsel Liste → Piece → Liste; Scrollzustand ist querygebunden implementiert | erfüllt am 27.10.2026 |
| Eingebetteter Piece-Editor | Schreiben liegt mittig, Metadaten rechts; Anlage, Autosave, Reload, Konflikt und Upload laufen ohne Desktopdialog | erfüllt am 27.10.2026 |
| Markdown und Rich Text | Gemeinsame Tiptap-Komponente in Original und Usage; Quellansicht speichert Markdown und sichere Vorschau bleibt bereinigt | erfüllt am 27.10.2026 |
| Bestehende Fachgrenzen | 60 Verhaltenstests sowie Browserabläufe für Snapshots, Werkzeuge, Live und Präsentation bleiben erfolgreich | erfüllt am 27.10.2026 |
| Responsive und Themes | Chromium-Matrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 27.10.2026 |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run lint`: erfolgreich.
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 20 von 20 Chromium-Abläufen erfolgreich.
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
## Lesender MCP-Zugang 0.7.3 ## Lesender MCP-Zugang 0.7.3
| Bereich | Nachweis | Stand | | Bereich | Nachweis | Stand |
+193 -41
View File
@@ -19,6 +19,70 @@ gelangen. Routen liefern nur die für ihre Ansicht erlaubten Daten. Fachlogik
wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die
spätere API dieselben Regeln verwenden. spätere API dieselben Regeln verwenden.
## Navigation, Suche und Arbeitsflächen
`+layout.svelte` stellt für angemeldete Nutzer 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.
Die Root-Route komponiert das Dashboard ausschließlich aus `listShows()`,
`getShow()`, `listPieces()` und den vorhandenen Verwendungskontexten. Sie besitzt
keinen eigenen persistenten Zustand. Pieces und Streams verwenden getrennte,
lokal gespeicherte Ein-/Ausklappzustände ihrer Kontextnavigation; URL-Parameter
bleiben die fachliche Quelle für lokale Suche und Filter.
`global-search.ts` ist eine serverseitige, ausschließlich hinter `requireUser`
verwendete Projektion. Sie durchsucht aktive und archivierte Pieces einschließlich
redaktioneller Langtexte sowie Metadaten und Usage-Snapshots aller Streams. Die
Route `/suche` gibt gruppierte Positivprojektionen mit ID, Titel, Status, internem
Ziel, Feldbezeichnung und begrenztem Ausschnitt aus. Gelöschte Pieces werden nicht
global angeboten. Es gibt keinen externen Index und keine neue Persistenz.
Die normale Piece-Arbeitsmenge schließt Originale mit materialisierter
Verwendungshistorie aus. Dieselben Originale werden unter `view=completed` mit
ihren verlinkten Streams angezeigt und bleiben über die bestehenden
Planungsfilter erneut einplanbar. Abgeschlossene Streams werden nicht mehr als
eigener globaler Archivbereich behandelt, sondern von `listShows()` in der
Streamübersicht gefiltert. Der technische `/archiv`-Pfad bleibt für archivierte
und gelöschte Pieces bestehen.
`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
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-, 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 ## Laufzeit und Speicher
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar. - Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
@@ -48,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 | | 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 | | 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 | | 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 | | Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel ### Historien- und Kopierregel
@@ -377,44 +442,90 @@ Sendungsroute bindet Mutationen zusätzlich an ihre eigene Sendungs-ID. Die
Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen
Status und Termin. 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 ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Start, Eingang, Pieces, Streams, Live und Post;
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an Verwaltung bleibt im Benutzermenü.
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und Auf dem Desktop liegt sie als feste horizontale Glasleiste oberhalb der
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten Arbeitsfläche. Bis einschließlich `52rem` ersetzen eine kompakte obere Leiste
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie und die untere Hauptnavigation diesen Desktopkopf vollständig. Auf breiten
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem`
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch
schmale Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare, Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
touchfreundliche Agenda desselben Monats. touchfreundliche Agenda desselben Monats.
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die Die private Route `/live` läuft in derselben authentifizierten App-Shell wie
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht Streams. Ein Streamkopf zeigt Titel, Termin, Umfang und den expliziten
ohne interne Anwendungsnavigation. 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 `src/routes/live/+page.server.ts` bietet neben `load` ausschließlich die getrennte
Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder Präsentationsaktion an. Das Auswählen einer Usage, Vor/Zurück, Schriftgröße,
Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster sichtbare Zeiten und das Kopieren von Twitch-Tags bleiben lokaler
aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie Browserzustand. Status-, Fortschritts-, Sortier- und Abschlussänderungen sind
fester Aktionszeile. Die Breite entscheidet über zwei Spalten oder das mobile nicht Teil dieser Route. Präsentieren ändert nur `presentation_usage_id` und
Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten, damit weder Show-/Usage-Status noch Reihenfolge. Die öffentliche `/heinzlive`-
ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints Projektion und die serverseitigen historischen Live-Dienste bleiben erhalten.
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.
Der Live-Kopf besteht aus zwei regulären Rasterzeilen. Abschnitt und folgendes Das gemeinsame Arbeitsraster besteht aus einer linken, über Kopf und Inhalt
Piece sind einzeilig gekürzte, fokussierbare Schaltflächen, die ihren vollständigen reichenden Navigation sowie einem rechten Hauptbereich. Dessen freie Kopfzeile
Text lokal aufklappen. Das Regiemenü wird bei Bedarf als fest am Viewport besitzt bewusst keine Pane-Fläche; darunter beginnen Inhalt und optionaler
positionierter Dialog neben dem Kopf gerendert. Dadurch unterliegt es weder dem Inspector. `unified-workspace`, Post-Detail und Live verwenden dieselben
Overflow der Regiefläche noch dem durch Glasfilter erzeugten Stacking Context; Spaltenwerte `13–16rem / minmax(0, 1fr) / 19–22rem` und `0.85rem` Abstand. Bei
seine maximale Höhe bleibt an den Viewport gebunden und intern scrollbar. 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 Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren `static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
@@ -422,17 +533,58 @@ Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog
nicht in ihren Client-Chunk. nicht in ihren Client-Chunk.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles Der Fundus ist auf breiten Ansichten ein Raster aus Bibliotheksnavigation,
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung, mittlerer Liste und rechter Schnellbearbeitung. Kleinere Desktopfenster ordnen
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche. den Inspector unter der Liste an; mobil werden Navigation, Liste und Inspector
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten gestapelt. URL-Parameter tragen Suche, Filter und ausgewählte Piece-ID über den
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen Routenwechsel, `sessionStorage` ausschließlich die zugehörige flüchtige
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht Listenposition. Reduzierte Schnellformulare lesen nicht dargestellte Werte
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und serverseitig aus dem aktuellen Piece und führen sie unverändert mit.
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die Der eigenständige Piece-Editor ordnet denselben Formvertrag als modales
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige redaktionelles Arbeitsblatt mit eigener URL an. Titel, Einleitung, Moderationsstichpunkte,
Mutationen. 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
bloße Initialisieren keinen Input auslöst. Weicht die erste Serialisierung vom
gespeicherten Text ab, startet das Feld in der Markdown-Quellansicht. Der Nutzer
kann diese Ansicht jederzeit erreichen; Autosave erzeugt die Editorinstanz nicht
neu.
## Sicherheitsgrenzen ## Sicherheitsgrenzen
+195
View File
@@ -570,3 +570,198 @@ Quellen, geprüft am 27. Oktober 2026:
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning> - <https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning>
- <https://modelcontextprotocol.io/specification/2026-07-28/server/discover> - <https://modelcontextprotocol.io/specification/2026-07-28/server/discover>
- <https://modelcontextprotocol.io/specification/2026-07-28/server/tools> - <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
## D-035: Horizontale Fundus-Werkbank und Markdown als Editorgrenze
Die Desktopnavigation wird horizontal, damit der Fundus die gesamte Breite als
drei fachlich eindeutige Spalten nutzen kann: Navigation, Liste oder Schreiben,
sowie Metadaten und Präsentation. Das Öffnen eines Pieces wechselt nur den
mittleren Arbeitskontext; Filter bleiben URL-Zustand, die Listenposition bleibt
flüchtiger Browserzustand. Bei weniger Platz verschwindet zuerst die rechte
Kontextspalte. Mobil wird nicht das Desktopgitter gestapelt, sondern zwischen
kompakten, einzeln bedienbaren Ansichten gewechselt.
Tiptap ist ausschließlich eine Browser-Bearbeitungsschicht. Markdown bleibt
persistentes und öffentliches API-Format, die vorhandene serverseitige
Marked-/sanitize-html-Grenze bleibt unverändert. `@tiptap/core`, `@tiptap/pm`,
`@tiptap/starter-kit` und `@tiptap/markdown` werden versionsgleich in 3.32.0
verwendet und sind MIT-lizenziert. Weil die Markdown-Erweiterung noch als frühe
Veröffentlichung dokumentiert ist, löst Initialisierung keine Speicherung aus;
abweichende Roundtrips öffnen die jederzeit erreichbare Quellansicht statt den
gespeicherten Text still umzuschreiben.
Quellen, geprüft am 27. Oktober 2026:
- <https://tiptap.dev/docs/editor/getting-started/install/svelte>
- <https://tiptap.dev/docs/editor/markdown/getting-started/basic-usage>
- npm-Metadaten der vier Tiptap-Pakete 3.32.0
## D-036: Begriffe und Navigation sind Projektionen, keine neuen Fachmodelle
Die sichtbaren Begriffe Pieces und Streams ersetzen Fundus und Sendungen in der
Bedienoberfläche. Persistierte Tabellen, Statuswerte, API-Pfade und bestehende
Browserrouten werden nicht umbenannt: Eine rein visuelle Neuordnung rechtfertigt
weder eine riskante Datenmigration noch einen Bruch externer Verträge.
Start ist eine aus vorhandenen Diensten abgeleitete Projektion. Die globale
Suche bleibt ein synchroner serverseitiger SQLite-Leseweg ohne eigenen Index.
Nur angemeldete Nutzer erreichen sie; Treffer enthalten kurze Ausschnitte statt
vollständiger Dokumente. Lokale Bereichssuchen und URL-Filter bleiben davon
unabhängig.
Bereits gesendete Pieces bilden keinen neuen persistenten Status. Wie bisher ist
die materialisierte Verwendungshistorie die Quelle; sie bestimmt nun zusätzlich
die sichtbare Gruppe Abgeschlossen. Ebenso werden die sichtbaren Streamzustände
Angelegt und In Vorbereitung aus `draft` plus vorhandener Abschnittszahl
abgeleitet. Die bestehenden Status- und Snapshotregeln bleiben damit alleinige
Fachquelle.
Der rechte Usage-Editor ist auf Desktop ein nicht-modales Dialogelement. Dadurch
bleiben dieselbe Komponente, Validierung und Form-Action erhalten, während der
Ablauf daneben bedienbar bleibt. Mobil verwendet dieselbe Komponente weiterhin
einen modalen Vollbildkontext.
## D-037: Arbeitsflächenhierarchie ohne neue Zustandsgrenzen
Das 0.8.1-Redesign verändert die visuelle Gewichtung, nicht die fachlichen
Aggregate. Piece-Editor, Streamplan und Live-Regie behalten ihre vorhandenen
Form-Actions, Revisionen, Befehls-IDs, Snapshots und getrennten Scroll- sowie
Fokuszustände. Die Piece-Bibliothek im Streamplan wird nur flüchtig ein- und
ausgeblendet; URL, Filter und Planungsdaten bleiben davon unberührt.
Suchfilter sind dagegen fachliche Leseparameter und werden deshalb serverseitig
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.
+216
View File
@@ -1,5 +1,221 @@
# Release Notes # 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
- Der Piece-Editor beginnt direkt unter dem Titel mit Einleitung,
Moderationsstichpunkten, Interaktion und Überleitung. Die ruhig begrenzte
Schreibspalte steht der dauerhaft rechten Metadaten- und Präsentationsfläche
gegenüber; Recherche, Quellen, Werkzeuge, Uploads und Testansicht bleiben
vollständig erhalten.
- Die Streamplanung priorisiert den breiten Ablauf. Abschnitte sind klar
abgegrenzte Blöcke, Pieces kompakte Zeilen und die durchsuchbare Piece-Auswahl
öffnet sich erst auf Anforderung neben dem Plan. Direktanlage, Reservebank,
Verwendungsfilter sowie Drag-and-drop und seine Klick-/Tastaturalternativen
bleiben unverändert funktionsfähig.
- Die Live-Regie hebt aktuelles Piece, Abschnitt und Moderationsinhalte stärker
hervor. Ausgabe, Gesamt- und Abschnittszeit sowie Ablauf bleiben schnell
erfassbar; mobile Ablaufleiste, Timer, Fortschrittsaktionen, Recherche,
Präsentation und OBS-Ausgabekanal behalten ihre bisherigen Zustandsgrenzen.
- Die globale Suche besitzt eine einklappbare Filterspalte für Ergebnisart,
Status, Piece-Thema und Piece-Format. Trefferzahlen, aktive Filter und
Zurücksetzen sind sichtbar; archivierte Pieces und abgeschlossene Streams
bleiben auffindbar. Filter werden serverseitig aus URL-Parametern angewendet.
- Die globale Desktop- und Mobilnavigation, Routen, Form-Actions, Revisionen,
Snapshotgrenzen und öffentlichen Datenprojektionen sind unverändert. Es gibt
keine Datenbankmigration.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.1`.
- Lint, Typprüfung, 63 Verhaltenstests, Produktionsbuild und 21
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
mangels Umgebung offen.
## 0.8.0 - Redaktioneller Arbeitsbereich
Veröffentlicht am 27. Oktober 2026
- Die globale Navigation besteht aus Start, Eingang, Pieces, Streams und Live.
Globale Suche und ein gemeinsames Benutzermenü ergänzen Desktopkopf und mobile
Suchleiste; Theme, Verwaltung und Abmeldung sind dort explizit erreichbar.
- Die neue Startseite zeigt ausschließlich vorhandene redaktionelle Daten: den
nächsten Stream mit Ablauf, aktuelle Pieces, Schnellaktionen und weitere
geplante Streams.
- Die angemeldeten Nutzern vorbehaltene globale Suche gruppiert Pieces und
Streams, berücksichtigt archivierte beziehungsweise abgeschlossene Inhalte
sowie private Redaktionstexte und zeigt kurze Trefferkontexte.
- Pieces und Streams besitzen einklappbare Kontextnavigationen und unabhängige
lokale Suchen. Gesendete Pieces liegen automatisch unter Abgeschlossen;
abgeschlossene Streams bleiben samt gespeichertem Ablauf unter Streams.
- Die Piece-Übersicht nutzt kompakte vollständig anklickbare Zeilen ohne
Bildplatzhalter oder leere rechte Kontextspalte. Der Usage-Editor öffnet auf
Desktop als nicht-modale rechte Arbeitsleiste und mobil als Vollbilddialog.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.0`. Es gibt keine
Datenbankmigration und keine Änderung persistierter Status- oder API-Verträge.
- Lint, Typprüfung, 62 Verhaltenstests, Produktionsbuild und 21
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
mangels Umgebung offen.
## 0.7.4 - Horizontale Fundus-Werkbank
Veröffentlicht am 27. Oktober 2026
- Die globale Desktopnavigation verläuft horizontal. Der Fundus verwendet die
freie Breite als dreispaltige Werkbank aus Navigation, Liste beziehungsweise
Editor sowie Metadaten und öffentlicher Präsentation.
- Der Piece-Editor ersetzt beim Öffnen die mittlere Liste und ist kein
Desktopdialog mehr. Filterparameter und Listenposition bleiben bei der
Rückkehr erhalten; schmalere und mobile Ansichten reduzieren die Spalten
kontrolliert statt sie vollständig untereinander zu stapeln.
- Fundus- und Sendungsfassungen teilen einen Tiptap-Editor für Langtexte.
Markdown bleibt Speicher- und API-Format. Eine jederzeit erreichbare
Quellansicht schützt Inhalte, die sich nicht verlustfrei visuell abbilden
lassen; das bloße Öffnen erzeugt keine Speicherung.
- Bestehende Revisions-, Autosave-, Quellen-, Werkzeug-, Medien-, Snapshot- und
Präsentationsgrenzen bleiben unverändert. Die rechte Präsentationsfläche nutzt
weiterhin den gemeinsamen sicheren Renderer und verändert keinen OBS-Kanal.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.4`; es gibt keine
Datenbankmigration. Die vier offiziellen Tiptap-Pakete sind auf Version 3.32.0
gepinnt und MIT-lizenziert.
- Lint, Typprüfung, 60 Verhaltenstests, Produktionsbuild und 20
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
mangels Umgebung offen.
## 0.7.3 - Lesender MCP-Zugang mit OAuth 2.1 ## 0.7.3 - Lesender MCP-Zugang mit OAuth 2.1
Veröffentlicht am 27. Oktober 2026 Veröffentlicht am 27. Oktober 2026
+260
View File
@@ -8,6 +8,266 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert. dokumentiert.
## 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
Titel in die vier Streamtextbereiche. Die Textbreite bleibt ruhig begrenzt;
Metadaten und alle öffentlichen Präsentationsarten liegen rechts, während
Recherche, Quellen, Zeitmarken, Werkzeuge, Uploads und Testausgabe erhalten
bleiben.
- Die Streamplanung verwendet den Ablauf als breite Hauptfläche. Die
durchsuchbare Piece-Bibliothek öffnet nur bei Bedarf daneben; auf schmalen
Ansichten bleibt die bestehende Umschaltung erhalten. Abschnittsblöcke und
kompakte Piece-Zeilen verwenden weiterhin dieselben Drag-, Klick-, Touch- und
Tastaturaktionen sowie dieselben revisionsgeschützten Form-Actions.
- Die Live-Regie priorisiert Piece, Abschnitt und Moderation stärker und zeigt
Position und Abschnitt direkt am aktuellen Inhalt. Timer, Ausgabe, mobiler
Ablauf, lokale Betrachtung, Präsentation und OBS-Kanal bleiben fachlich
unverändert getrennt.
- Die Suche verwendet eine linke, mobil ausfahrbare Filterspalte. Ergebnisart,
Status, Piece-Thema und Piece-Format werden serverseitig über URL-Parameter
gefiltert; Trefferzahlen und Zurücksetzen bleiben ohne neuen Suchindex oder
zusätzliche Persistenz verfügbar.
- Version `0.8.1` benötigt keine Migration und ändert weder globale Navigation
noch URLs, API-Verträge, Rechte- oder Sicherheitsprojektionen.
Geprüft wurden Lint, Typprüfung, Produktionsbuild, 63 von 63 Verhaltenstests und
21 von 21 Chromium-Abläufe. Die gezielte Sichtkontrolle umfasste Piece-Editor,
Streamplanung, Live-Regie und Suche bei 1440 × 1000 sowie 390 × 844 Pixeln;
Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
## Redaktioneller Arbeitsbereich 0.8.0
- Die sichtbare Anwendungssprache verwendet durchgehend Pieces und Streams; die
stabilen technischen Routen und Datenmodelle bleiben unverändert. Desktop und
Mobilgerät teilen die fünf Hauptbereiche Start, Eingang, Pieces, Streams und
Live. Suche, explizite Theme-Auswahl, Verwaltung und Abmeldung liegen in der
gemeinsamen Kopfzeile beziehungsweise im Benutzermenü.
- `/` ist ein echtes Dashboard aus vorhandenem nächsten Stream, dessen kompaktem
Ablauf, aktuellen Pieces, Schnellaktionen und weiteren geplanten Streams. Es
erzeugt weder Aufgaben noch andere redaktionelle Daten.
- Die globale, angemeldeten Nutzern vorbehaltene Suche gruppiert Treffer nach
Pieces und Streams. Sie berücksichtigt archivierte Pieces, abgeschlossene
Streams, private redaktionelle Langtexte und eingefrorene Stream-Snapshots und
liefert nur einen kurzen Trefferkontext mit Fundort.
- Pieces und Streams besitzen einklappbare Kontextnavigationen mit lokaler Suche.
Bereits gesendete Pieces verschwinden aus dem normalen Arbeitsbestand und
liegen mit verlinkter Verwendungshistorie unter Abgeschlossen; archivierte und
gelöschte Pieces bleiben unter Pieces erreichbar. Abgeschlossene Streams sind
über den Streamfilter erreichbar und behalten den gespeicherten Ablauf.
- Die Piece-Liste ist eine kompakte, vollständig anklickbare Zeilenansicht ohne
erzwungene Bildplatzhalter. Ohne Auswahl existiert keine rechte Leerspalte. In
der Streamplanung öffnet der bestehende vollständige Usage-Editor auf Desktop
als nicht-modale rechte Arbeitsleiste und mobil weiterhin als Vollbilddialog.
- Für diese Änderung war keine Datenbankmigration nötig. Rollen, Rechte,
Revisionsschutz, Snapshotgrenzen, Markdown-Speicherung und bestehende Actions
bleiben erhalten.
Geprüft wurden 62 von 62 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
21 von 21 Chromium-Abläufe. Dashboard, globale Suche, abgeschlossene Pieces und
Streams, Desktop-/Mobilnavigation, rechte Usage-Arbeitsleiste sowie die
bestehenden Rollen-, Live-, Freigabe-, Autosave- und Präsentationsabläufe waren
erfolgreich. Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
## Horizontale Fundus-Werkbank 0.7.4
- Die allgemeine Desktopnavigation verläuft horizontal über der Anwendung. Der
Fundus nutzt darunter eine dreispaltige Werkbank aus Bibliotheksnavigation,
Liste beziehungsweise Schreibfläche sowie Kontext und Präsentation. Bei
kleinerem Platz fällt zuerst die Kontextspalte weg; mobil werden Navigation
und Editor gezielt einzeln statt als gestauchte Dreispaltenansicht gezeigt.
- Fundusnavigation, Status, Favoriten, Archiv, Papierkorb und Themen zeigen aus
den bestehenden Daten abgeleitete Zähler. Suche, Taxonomiefilter,
Verwendungsfilter und Listenposition bleiben beim Wechsel in ein Piece und
zurück erhalten.
- Der Fundus-Editor ist auf Desktop kein Dialog mehr. Sendungstext und private
Recherche stehen dauerhaft in der mittleren Schreibfläche; Status, Dauer,
Einordnung und die öffentliche Präsentationsfläche liegen ausschließlich
rechts. Bestehende Autosave-, Revisions-, Quellen-, Werkzeug-, Medien- und
Präsentationsactions bleiben unverändert getrennt.
- Alle formatierbaren Langtexte in Fundus- und Sendungsfassung verwenden eine
gemeinsame Tiptap-Komponente. Gespeichert und an Form-Actions übermittelt wird
weiterhin Markdown. Eine erreichbare Quellansicht übernimmt nicht verlustfrei
abbildbares Markdown, ohne es bereits beim Öffnen umzuschreiben.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und die
gezielten Chromium-Abläufe für Fundus, Piece-Autosave, Sendungsfassung,
Werkzeuge, Live-Snapshots sowie Link-/Videopräsentation. Die abschließende
vollständige Browsermatrix ist im Abnahmenachweis festgehalten.
## Lesender MCP-Zugang 0.7.3 ## Lesender MCP-Zugang 0.7.3
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und - Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und
+127
View File
@@ -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;
+15
View File
@@ -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 = '';
+567 -2
View File
@@ -1,14 +1,18 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.3", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.3", "version": "0.9.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"@tiptap/core": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2", "fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0", "file-type": "^22.2.0",
@@ -1234,6 +1238,404 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz",
"integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz",
"integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz",
"integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz",
"integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz",
"integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz",
"integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz",
"integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz",
"integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.32.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz",
"integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz",
"integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz",
"integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz",
"integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz",
"integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz",
"integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz",
"integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz",
"integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz",
"integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.32.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz",
"integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz",
"integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz",
"integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz",
"integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz",
"integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/markdown": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.32.0.tgz",
"integrity": "sha512-y+pGAu4QMkXBMxhBoLvc6FWucQ1EWIoPi+Ajf4rAh5OlZtSV3CjBwyK6/xTwO7mvQxtoUf7DD23CzSaj5nCTBA==",
"license": "MIT",
"dependencies": {
"marked": "^17.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/markdown/node_modules/marked": {
"version": "17.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
"integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@tiptap/pm": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.4",
"prosemirror-commands": "^1.7.2",
"prosemirror-dropcursor": "^1.8.4",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.1",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.12",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.2",
"prosemirror-view": "^1.42.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz",
"integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.32.0",
"@tiptap/extension-blockquote": "3.32.0",
"@tiptap/extension-bold": "3.32.0",
"@tiptap/extension-bullet-list": "3.32.0",
"@tiptap/extension-code": "3.32.0",
"@tiptap/extension-code-block": "3.32.0",
"@tiptap/extension-document": "3.32.0",
"@tiptap/extension-dropcursor": "3.32.0",
"@tiptap/extension-gapcursor": "3.32.0",
"@tiptap/extension-hard-break": "3.32.0",
"@tiptap/extension-heading": "3.32.0",
"@tiptap/extension-horizontal-rule": "3.32.0",
"@tiptap/extension-italic": "3.32.0",
"@tiptap/extension-link": "3.32.0",
"@tiptap/extension-list": "3.32.0",
"@tiptap/extension-list-item": "3.32.0",
"@tiptap/extension-list-keymap": "3.32.0",
"@tiptap/extension-ordered-list": "3.32.0",
"@tiptap/extension-paragraph": "3.32.0",
"@tiptap/extension-strike": "3.32.0",
"@tiptap/extension-text": "3.32.0",
"@tiptap/extension-underline": "3.32.0",
"@tiptap/extensions": "3.32.0",
"@tiptap/pm": "3.32.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
@@ -3578,6 +3980,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/linkifyjs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz",
"integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==",
"license": "MIT"
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -3986,6 +4394,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4400,6 +4814,145 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4513,6 +5066,12 @@
"@rolldown/binding-win32-x64-msvc": "1.2.14" "@rolldown/binding-win32-x64-msvc": "1.2.14"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/sade": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -5276,6 +5835,12 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+5 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.7.3", "version": "0.9.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -25,6 +25,10 @@
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"@tiptap/core": "^3.32.0",
"@tiptap/markdown": "^3.32.0",
"@tiptap/pm": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2", "fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0", "file-type": "^22.2.0",
+28 -1
View File
@@ -296,6 +296,17 @@ export function seed(databasePath = getPaths().database) {
1, 1,
timestamp, timestamp,
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-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0],
['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 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], ['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(); .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(); seedDatabase();
+5696 -10
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props(); let { name, label, size = '1.1em' }: { name: string; label?: string; size?: string } = $props();
</script> </script>
<svg <svg
+320
View File
@@ -0,0 +1,320 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- all links are closed local routes assembled below */
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
filters,
counts,
topics,
formats = [],
categories = [],
editor,
oneditorclose
}: {
filters: {
query: string;
status: string;
formatId?: string;
topicId: string;
categoryId?: string;
includeUsed?: boolean;
includePlanned?: boolean;
favorites: boolean;
view: string;
};
counts: {
all: number;
inbox: number;
preparing: number;
ready: number;
favorites: number;
completed: number;
archived: number;
deleted: number;
topics: Record<string, number>;
};
topics: { id: string; name: string; iconName: string }[];
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<HTMLInputElement>();
onMount(() => {
collapsed = localStorage.getItem('sendewerk-pieces-navigation') === 'collapsed';
});
function toggle() {
collapsed = !collapsed;
localStorage.setItem('sendewerk-pieces-navigation', collapsed ? 'collapsed' : 'open');
}
async function revealSearch() {
if (collapsed) toggle();
await tick();
searchInput?.focus();
}
const fundusLinks = $derived([
{
href: '/fundus',
label: 'Alle Pieces',
icon: 'library',
count: counts.all,
active: !filters.status && !filters.favorites && !filters.view
},
{ href: '/eingang', label: 'Eingang', icon: 'inbox', count: counts.inbox, active: false },
{
href: '/fundus?view=completed',
label: 'Abgeschlossen',
icon: 'circle-check',
count: counts.completed,
active: filters.view === 'completed'
},
{
href: '/fundus?status=preparing',
label: 'In Vorbereitung',
icon: 'pen',
count: counts.preparing,
active: filters.status === 'preparing'
},
{
href: '/fundus?status=ready',
label: 'Sendefertig',
icon: 'circle-check',
count: counts.ready,
active: filters.status === 'ready'
},
{
href: '/fundus?favorites=1',
label: 'Favoriten',
icon: 'star',
count: counts.favorites,
active: filters.favorites
},
{
href: '/archiv',
label: 'Archiv',
icon: 'box-archive',
count: counts.archived,
active: false
},
{
href: '/archiv#deleted-pieces-title',
label: 'Papierkorb',
icon: 'trash',
count: counts.deleted,
active: false
}
]);
let editorPieces = $derived(
editor
? editor.pieces.some((piece) => piece.id === editor.current.id)
? editor.pieces
: [editor.current, ...editor.pieces]
: []
);
const statusLabels: Record<string, string> = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig',
archived: 'Archiviert'
};
</script>
<aside
class="fundus-navigation"
class:collapsed
class:editor-context={editor}
aria-label={editor ? 'Piece wechseln' : 'Pieces-Navigation'}
>
<header>
<div>
<p class="eyebrow">{editor ? 'Aktuelle Auswahl' : 'Bibliothek'}</p>
<h1>{editor ? 'Piece wechseln' : 'Pieces'}</h1>
</div>
<button
type="button"
onclick={toggle}
aria-label={collapsed ? 'Pieces-Navigation ausklappen' : 'Pieces-Navigation einklappen'}
aria-expanded={!collapsed}
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
>
{#if editor && oneditorclose}<button
class="editor-navigation-close"
type="button"
onclick={oneditorclose}
aria-label="Piece-Liste schließen"><AppIcon name="xmark" /></button
>{/if}
</header>
{#if editor}
<a class="editor-overview-link" href={`/fundus${editor.search}`}
><AppIcon name="chevron-left" /><span>Zur Übersicht</span></a
>
{#if !collapsed}
<form
class="context-search editor-context-search"
method="GET"
action={`/fundus/${editor.current.id}`}
role="search"
>
{#if filters.status}<input type="hidden" name="status" value={filters.status} />{/if}
{#if filters.formatId}<input type="hidden" name="format" value={filters.formatId} />{/if}
{#if filters.topicId}<input type="hidden" name="topic" value={filters.topicId} />{/if}
{#if filters.categoryId}<input
type="hidden"
name="category"
value={filters.categoryId}
/>{/if}
{#if filters.favorites}<input type="hidden" name="favorites" value="1" />{/if}
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
{#if filters.includeUsed}<input type="hidden" name="includeUsed" value="1" />{/if}
{#if filters.includePlanned}<input type="hidden" name="includePlanned" value="1" />{/if}
<label
><AppIcon name="magnifying-glass" /><span class="sr-only">Pieces durchsuchen</span><input
bind:this={searchInput}
name="q"
type="search"
value={filters.query}
placeholder="Auswahl durchsuchen"
/></label
>
</form>
<details class="editor-piece-filters">
<summary><AppIcon name="list-check" /> Filter</summary>
<form method="GET" action={`/fundus/${editor.current.id}`}>
{#if filters.query}<input type="hidden" name="q" value={filters.query} />{/if}
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
{#if filters.includeUsed}<input type="hidden" name="includeUsed" value="1" />{/if}
{#if filters.includePlanned}<input type="hidden" name="includePlanned" value="1" />{/if}
<label
>Status<select name="status" aria-label="Nach Bearbeitungsstand filtern"
><option value="">Aktive Pieces</option><option
value="preparing"
selected={filters.status === 'preparing'}>In Vorbereitung</option
><option value="ready" selected={filters.status === 'ready'}>Sendefertig</option
></select
></label
>
<label
>Format<select name="format" aria-label="Nach Format filtern"
><option value="">Alle Formate</option>{#each formats as format (format.id)}<option
value={format.id}
selected={filters.formatId === format.id}>{format.name}</option
>{/each}</select
></label
>
<label
>Thema<select name="topic" aria-label="Nach Thema filtern"
><option value="">Alle Themen</option>{#each topics as topic (topic.id)}<option
value={topic.id}
selected={filters.topicId === topic.id}>{topic.name}</option
>{/each}</select
></label
>
<label
>Abschnitt<select name="category" aria-label="Nach Abschnitt filtern"
><option value="">Alle Abschnitte</option
>{#each categories as category (category.id)}<option
value={category.id}
selected={filters.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label class="editor-filter-check"
><input type="checkbox" name="favorites" value="1" checked={filters.favorites} /> Nur Favoriten</label
>
<button class="secondary-button" type="submit">Filter anwenden</button>
</form>
</details>
{/if}
<nav class="editor-piece-list" aria-label="Gefilterte Pieces">
{#each editorPieces as piece (piece.id)}
<a
href={`/fundus/${piece.id}${editor.search}`}
class:active={piece.id === editor.current.id}
aria-current={piece.id === editor.current.id ? 'page' : undefined}
>
<AppIcon name={piece.isFavorite ? 'star' : 'newspaper'} />
<span
><strong>{piece.title}</strong><small
>{statusLabels[piece.status] ?? piece.status} · {Math.round(
piece.durationSeconds / 60
)} Min.</small
></span
>
</a>
{/each}
</nav>
{:else if collapsed}
<button
class="collapsed-search-button"
type="button"
onclick={revealSearch}
aria-label="Pieces durchsuchen"><AppIcon name="magnifying-glass" /></button
>
{:else}
<form class="context-search" method="GET" action="/fundus" role="search">
{#if filters.status}<input type="hidden" name="status" value={filters.status} />{/if}
{#if filters.topicId}<input type="hidden" name="topic" value={filters.topicId} />{/if}
{#if filters.favorites}<input type="hidden" name="favorites" value="1" />{/if}
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
<label
><AppIcon name="magnifying-glass" /><span class="sr-only">Pieces durchsuchen</span><input
bind:this={searchInput}
name="q"
type="search"
value={filters.query}
placeholder="Pieces durchsuchen"
/></label
>
</form>
{/if}
{#if !editor}<nav>
{#each fundusLinks as item (item.href)}
<a
href={item.href}
class:active={item.active}
aria-current={item.active ? 'page' : undefined}
>
<AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong>
</a>
{/each}
</nav>{/if}
{#if !editor && topics.length}
<section>
<h2>Themen</h2>
<nav aria-label="Themenfilter">
{#each topics as topic (topic.id)}
<a
href={`/fundus?topic=${encodeURIComponent(topic.id)}`}
class:active={filters.topicId === topic.id}
>
<AppIcon name={topic.iconName || 'tag'} /><span>{topic.name}</span><strong
>{counts.topics[topic.id] ?? 0}</strong
>
</a>
{/each}
</nav>
</section>
{/if}
</aside>
@@ -1,85 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
name,
label,
value = $bindable(),
rows = 4,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let textarea: HTMLTextAreaElement;
async function replaceSelection(before: string, after = before, fallback = 'Text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || fallback;
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
async function addList() {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || 'Stichpunkt';
const replacement = selected
.split('\n')
.map((line) => `- ${line}`)
.join('\n');
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + 2, start + replacement.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
</script>
<div class:private-field={privateField} class="markdown-editor-field">
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
><strong>B</strong></button
>
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button
>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
><AppIcon name="list-check" /> Liste</button
>
<button
type="button"
aria-label="Link"
title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
><AppIcon name="link" /> Link</button
>
</span>
</div>
<textarea
bind:this={textarea}
aria-label={label}
{name}
{rows}
{placeholder}
{form}
maxlength="50000"
bind:value></textarea>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@
let iconName = $derived(aliases[name] ?? name); let iconName = $derived(aliases[name] ?? name);
</script> </script>
<AppIcon name={iconName} size="1.25rem" /> <AppIcon name={iconName} size="1.4rem" />
<style> <style>
:global(.app-icon) { :global(.app-icon) {
+55 -22
View File
@@ -6,6 +6,7 @@
labelledby, labelledby,
activeSection = $bindable(), activeSection = $bindable(),
onrequestclose, onrequestclose,
inline = false,
header, header,
children, children,
footer footer
@@ -13,12 +14,14 @@
labelledby: string; labelledby: string;
activeSection: 'script' | 'research' | 'presentation'; activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void; onrequestclose: () => void;
inline?: boolean;
header: Snippet; header: Snippet;
children: Snippet; children: Snippet;
footer: Snippet; footer?: Snippet;
} = $props(); } = $props();
let dialog: HTMLDialogElement; let dialog = $state<HTMLDialogElement>();
let closing = $state(false);
let workspace: HTMLDivElement; let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 }; const scrollPositions = { script: 0, research: 0, presentation: 0 };
@@ -30,39 +33,48 @@
} }
export function showModal() { export function showModal() {
if (!dialog.open) dialog.showModal(); if (!inline && dialog && !dialog.open) {
closing = false;
dialog.showModal();
}
} }
export function close() { export function close() {
dialog.close(); if (!inline) void requestClose();
}
async function requestClose() {
if (inline || closing) return;
closing = true;
await tick();
const closeAnimation = dialog
?.getAnimations()
.find((animation) => animation.effect instanceof KeyframeEffect);
if (closeAnimation) {
try {
await closeAnimation.finished;
} catch {
// A navigation may remove the dialog while its visual exit is finishing.
}
}
onrequestclose();
} }
</script> </script>
<dialog {#snippet editorChrome()}
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
<header class="piece-dialog-header"> <header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div> <div class="piece-dialog-heading">{@render header()}</div>
<button <button class="dialog-close" type="button" aria-label="Editor schließen" onclick={requestClose}
class="dialog-close" ><AppIcon name="xmark" /></button
type="button"
aria-label="Editor schließen"
onclick={onrequestclose}><AppIcon name="xmark" /></button
> >
</header> </header>
{#if !inline}
<nav class="piece-dialog-tabs" aria-label="Editorbereiche"> <nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button <button
type="button" type="button"
class:active={activeSection === 'script'} class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined} aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button onclick={() => selectSection('script')}>Streamtext</button
> >
<button <button
type="button" type="button"
@@ -77,6 +89,27 @@
onclick={() => selectSection('presentation')}>Präsentation</button onclick={() => selectSection('presentation')}>Präsentation</button
> >
</nav> </nav>
{/if}
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div> <div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer> {#if footer}<footer class="piece-dialog-footer">{@render footer()}</footer>{/if}
</dialog> {/snippet}
{#if inline}
<section class="piece-editor-dialog fundus-editor-surface" aria-labelledby={labelledby}>
{@render editorChrome()}
</section>
{:else}
<dialog
bind:this={dialog}
class:closing
class="piece-editor-dialog fundus-editor-surface"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
requestClose();
}}
onclick={(event) => event.target === dialog && requestClose()}
>
{@render editorChrome()}
</dialog>
{/if}
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- href() starts from resolve('/post') and adds local filters */
import { onMount, tick } from 'svelte';
import { resolve } from '$app/paths';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import AppIcon from './AppIcon.svelte';
let {
query,
progress,
sort,
counts
}: {
query: string;
progress: string;
sort: string;
counts: Record<string, number>;
} = $props();
let collapsed = $state(false);
let searchInput = $state<HTMLInputElement>();
const links = $derived([
{ label: 'Alle Streams', value: '', icon: 'clapperboard', count: counts.all },
{
label: 'Noch nicht begonnen',
value: 'Noch nicht begonnen',
icon: 'file',
count: counts.notStarted
},
{
label: 'In Bearbeitung',
value: 'In Bearbeitung',
icon: 'pen',
count: counts.inProgress
},
{ label: 'Fertig', value: 'Fertig', icon: 'circle-check', count: counts.done },
{
label: 'Veröffentlicht',
value: 'Veröffentlicht',
icon: 'display',
count: counts.published
}
]);
onMount(() => {
collapsed = localStorage.getItem('sendewerk-post-navigation') === 'collapsed';
});
function toggle() {
collapsed = !collapsed;
localStorage.setItem('sendewerk-post-navigation', collapsed ? 'collapsed' : 'open');
}
async function revealSearch() {
if (collapsed) toggle();
await tick();
searchInput?.focus();
}
function href(value: string) {
const params = new SvelteURLSearchParams();
if (query) params.set('q', query);
if (value) params.set('progress', value);
if (sort !== 'newest') params.set('sort', sort);
const search = params.toString();
const base = resolve('/post');
return search ? `${base}?${search}` : base;
}
</script>
<aside class="fundus-navigation post-navigation-panel" class:collapsed aria-label="Post-Navigation">
<header>
<div>
<p class="eyebrow">Nach dem Stream</p>
<h1>Postproduktion</h1>
</div>
<button
type="button"
onclick={toggle}
aria-label={collapsed ? 'Post-Navigation ausklappen' : 'Post-Navigation einklappen'}
aria-expanded={!collapsed}
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
>
</header>
{#if collapsed}
<button
class="collapsed-search-button"
type="button"
onclick={revealSearch}
aria-label="Postproduktion durchsuchen"><AppIcon name="magnifying-glass" /></button
>
{:else}
<form class="context-search" method="GET" action="/post" role="search">
{#if progress}<input type="hidden" name="progress" value={progress} />{/if}
{#if sort !== 'newest'}<input type="hidden" name="sort" value={sort} />{/if}
<label
><AppIcon name="magnifying-glass" /><span class="sr-only">Streams durchsuchen</span><input
bind:this={searchInput}
name="q"
type="search"
value={query}
placeholder="Streams durchsuchen"
/></label
>
</form>
{/if}
<nav>
{#each links as item (item.value)}
<a
href={href(item.value)}
class:active={progress === item.value}
aria-current={progress === item.value ? 'page' : undefined}
><AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong></a
>
{/each}
</nav>
{#if !collapsed}
<section class="post-navigation-sort">
<h2>Sortierung</h2>
<form method="GET" action="/post">
{#if query}<input type="hidden" name="q" value={query} />{/if}
{#if progress}<input type="hidden" name="progress" value={progress} />{/if}
<label
><span class="sr-only">Sortierung</span><select
name="sort"
value={sort}
onchange={(event) => event.currentTarget.form?.requestSubmit()}
><option value="newest">Neueste zuerst</option><option value="oldest"
>Älteste zuerst</option
><option value="title">Titel</option><option value="clips">Meiste Clips</option></select
></label
>
</form>
</section>
{/if}
</aside>
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
import { Editor } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
name,
label,
value = $bindable(),
rows = 4,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let element: HTMLDivElement;
let valueInput: HTMLInputElement;
let editor = $state<Editor>();
let sourceMode = $state(false);
let sourceWarning = $state(false);
let transaction = $state(0);
function announceChange(markdown: string) {
value = markdown;
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
}
function command(run: (chain: ReturnType<Editor['chain']>) => boolean) {
if (!editor) return;
run(editor.chain().focus());
}
function active(name: string, attributes?: Record<string, unknown>) {
return transaction >= 0 && (editor?.isActive(name, attributes) ?? false);
}
function preserveSelection(event: MouseEvent) {
event.preventDefault();
}
function showRichText() {
if (!editor) return;
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
sourceMode = false;
}
function editLink() {
if (!editor) return;
const previous = String(editor.getAttributes('link').href ?? '');
const href = window.prompt('Linkadresse', previous || 'https://');
if (href === null) return;
if (!href.trim()) editor.chain().focus().extendMarkRange('link').unsetLink().run();
else editor.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
}
onMount(() => {
editor = new Editor({
element,
extensions: [StarterKit, Markdown],
content: value,
contentType: 'markdown',
editorProps: {
attributes: {
class: 'rich-markdown-content',
'aria-label': label,
'data-placeholder': placeholder
}
},
onUpdate: ({ editor: current }) => announceChange(current.getMarkdown()),
onTransaction: () => (transaction += 1)
});
const roundTrip = editor.getMarkdown().trimEnd();
if (value.trimEnd() !== roundTrip) {
sourceWarning = true;
sourceMode = true;
}
return () => editor?.destroy();
});
</script>
<div class:private-field={privateField} class="markdown-editor-field rich-markdown-field">
<input bind:this={valueInput} type="hidden" {name} {form} {value} />
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<button class="markdown-toolbar-toggle" type="button" aria-label={`${label} formatieren`}
><AppIcon name="pen" /> Formatieren</button
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
{#if !sourceMode}
<button
class:active={active('bold')}
type="button"
aria-label="Fett"
onmousedown={preserveSelection}
onclick={() => command((chain) => chain.toggleBold().run())}><strong>B</strong></button
>
<button
class:active={active('italic')}
type="button"
aria-label="Kursiv"
onmousedown={preserveSelection}
onclick={() => command((chain) => chain.toggleItalic().run())}><em>I</em></button
>
<button
class:active={active('bulletList')}
type="button"
aria-label="Liste"
onmousedown={preserveSelection}
onclick={() => command((chain) => chain.toggleBulletList().run())}
><AppIcon name="list-check" /></button
>
<button
class:active={active('heading', { level: 2 })}
type="button"
aria-label="Zwischenüberschrift"
onmousedown={preserveSelection}
onclick={() => command((chain) => chain.toggleHeading({ level: 2 }).run())}>H2</button
>
<button
class:active={active('link')}
type="button"
aria-label="Link"
onmousedown={preserveSelection}
onclick={editLink}><AppIcon name="link" /></button
>
{/if}
<button
type="button"
class:active={sourceMode}
onmousedown={preserveSelection}
onclick={() => (sourceMode ? showRichText() : (sourceMode = true))}
>{sourceMode ? 'Visuell' : 'Markdown'}</button
>
</span>
</div>
{#if sourceWarning}<p class="markdown-source-warning">
Dieser Text enthält Markdown, das visuell nicht verlustfrei abgebildet werden kann. Die
Quellansicht bleibt deshalb aktiv.
</p>{/if}
<textarea
hidden={!sourceMode}
aria-label={`${label}, Markdown-Quelltext`}
rows={Math.max(rows, 6)}
maxlength="50000"
bind:value></textarea>
<div hidden={sourceMode} class="rich-markdown-surface" bind:this={element}></div>
</div>
+5 -5
View File
@@ -123,7 +123,7 @@
</button> </button>
</div> </div>
<p class="catalog-note"> <p class="catalog-note">
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen. Streams gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
</p> </p>
</section> </section>
{:else} {:else}
@@ -153,7 +153,7 @@
</button> </button>
</div> </div>
<p class="share-intro"> <p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link Der Gast sieht nur diesen Stream und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt. wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p> </p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div {#if form?.intent?.toLowerCase().includes('share') && form.error}<div
@@ -187,8 +187,8 @@
<input type="hidden" name="showId" value={fixedShowId} /> <input type="hidden" name="showId" value={fixedShowId} />
{:else} {:else}
<label <label
>Sendung<select name="showId" required> >Stream<select name="showId" required>
<option value="">Sendung wählen</option> <option value="">Stream wählen</option>
{#each shows as show (show.id)}<option value={show.id} {#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option >{show.title} · {statusLabel(show.status)}</option
>{/each} >{/each}
@@ -317,7 +317,7 @@
</details> </details>
{/each} {/each}
{#if !shares.some((share) => share.showId === show.id)} {#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p> <p class="quiet-copy">Für diesen Stream gibt es noch keine Lesefreigabe.</p>
{/if} {/if}
</div> </div>
</article> </article>
+258
View File
@@ -0,0 +1,258 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import type { PieceUsage, Show, ShowSection } from '#lib/server/shows.js';
let {
show,
startsAtLocal,
categories,
section = null,
usage = null,
usageSectionName = '',
usageSectionId = '',
enhanceCommand,
copyText,
onshow
}: {
show: Show;
startsAtLocal: string;
categories: { id: string; name: string }[];
section?: ShowSection | null;
usage?: PieceUsage | null;
usageSectionName?: string;
usageSectionId?: string;
enhanceCommand: SubmitFunction;
copyText: (label: string, value: string) => Promise<void>;
onshow: () => void;
} = $props();
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
</script>
<aside class="stream-inspector builder-pane" aria-label="Streamdetails">
<header class="stream-inspector-header">
<div>
<p class="eyebrow">{usage ? 'Streamfassung' : section ? 'Abschnitt' : 'Stream'}</p>
{#if usage || section}<button class="text-button" type="button" onclick={onshow}
>Streamdaten</button
>{/if}
</div>
<h2>{usage?.title ?? section?.name ?? 'Streamdaten und Twitch-Texte'}</h2>
{#if usage}
<p>
{usageSectionName || 'Reservebank'} · {usage.topics.map((topic) => topic.name).join(', ') ||
'Ohne Thema'}
</p>
{:else if section}
<p>
{section.category ?? 'Ohne Kategorie'} · {section.usages.length}
{section.usages.length === 1 ? 'Piece' : 'Pieces'}
</p>
{:else}
<p>Schnelle Änderungen am geplanten Stream.</p>
{/if}
</header>
{#if usage}
<form
id="stream-inspector-form"
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsageMetadata"
class="stream-inspector-form"
>
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<label
>Interner Titel<input name="title" required maxlength="200" value={usage.title} /></label
>
<label
>Publikumstitel<input name="outputTitle" maxlength="200" value={usage.outputTitle} /></label
>
<div class="stream-inspector-grid">
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label
>Vorbereitungsstatus<select name="preparationStatus" value={usage.preparationStatus}
><option value="preparing">In Vorbereitung</option><option value="ready"
>Sendefertig</option
></select
></label
>
</div>
<label
>Vorbereitungsaufwand<select name="preparationEffort" value={usage.preparationEffort ?? ''}
><option value="">Nicht angegeben</option><option value="low">Gering</option><option
value="medium">Mittel</option
><option value="high">Hoch</option></select
></label
>
<label
>Eigene Notizen<textarea name="ownNotes" maxlength="20000" rows="7"
>{usage.ownNotes}</textarea
></label
>
</form>
<details class="stream-inspector-commands">
<summary>Weitere Aktionen</summary>
<div>
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit" disabled={!usage.originalPieceId}
>Aus Piece aktualisieren</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit">Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="position" value="9999" />
<label
><span class="sr-only">Ziel</span><select name="sectionId"
><option value="" selected={!usageSectionId}>Reservebank</option
>{#each show.sections as target (target.id)}<option
value={target.id}
selected={target.id === usageSectionId}>{target.name}</option
>{/each}</select
></label
>
<button class="text-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button danger-text" type="submit">Aus Stream entfernen</button>
</form>
</div>
</details>
<footer class="stream-inspector-actions">
{#if usage.originalPieceId}
<a class="secondary-button" href={resolve('/fundus/[id]', { id: usage.originalPieceId })}
>Piece bearbeiten</a
>
{:else}
<span class="stream-inspector-note">Das Original ist nicht mehr verfügbar.</span>
{/if}
<button class="primary-button" type="submit" form="stream-inspector-form">Speichern</button>
</footer>
{:else if section}
<form
id="stream-inspector-form"
use:enhance={enhanceCommand}
method="POST"
action="?/updateSection"
class="stream-inspector-form"
>
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="sectionId" value={section.id} />
<label>Name<input name="name" required maxlength="200" value={section.name} /></label>
<label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each categories as category (category.id)}<option
value={category.id}
selected={section.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label
>Explizite Dauer in Sekunden<input
name="explicitDurationSeconds"
type="number"
min="0"
max="86400"
value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces"
/></label
>
<label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
value={section.twitchTags.join(', ')}
placeholder="Gaming, Deutsch"
/></label
>
</form>
<footer class="stream-inspector-actions">
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeSection"
onsubmit={(event) =>
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={show.revision} />
<input type="hidden" name="sectionId" value={section.id} />
<button class="text-button danger-text" type="submit">Entfernen</button>
</form>
<button class="primary-button" type="submit" form="stream-inspector-form">Speichern</button>
</footer>
{:else}
<form
id="stream-inspector-form"
use:enhance={enhanceCommand}
method="POST"
action="?/saveMetadata"
class="stream-inspector-form"
>
<input type="hidden" name="revision" value={show.revision} />
<label>Interner Titel<input name="title" required maxlength="200" value={show.title} /></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={startsAtLocal} /><small
>Europe/Berlin</small
></label
>
<label
>Status<select name="status" value={show.status}
><option value="draft">Entwurf</option><option value="ready">Bereit</option><option
value="live">Live</option
></select
></label
>
<label
>Twitch-Titel<span class="copy-field"
><input name="twitchTitle" maxlength="200" value={show.twitchTitle} /><button
type="button"
onclick={() => copyText('Twitch-Titel', show.twitchTitle)}>Kopieren</button
></span
></label
>
<label
>Ankündigung<span class="copy-field"
><textarea name="announcementText" maxlength="20000" rows="7"
>{show.announcementText}</textarea
><button type="button" onclick={() => copyText('Ankündigung', show.announcementText)}
>Kopieren</button
></span
></label
>
</form>
<footer class="stream-inspector-actions single-action">
<button class="primary-button" type="submit" form="stream-inspector-form">Speichern</button>
</footer>
{/if}
</aside>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- filter links are fixed local routes assembled below */
import { onMount, tick } from 'svelte';
import { resolve } from '$app/paths';
import AppIcon from './AppIcon.svelte';
let {
query,
state: filterState,
counts,
shows
}: {
query: string;
state: string;
counts: Record<string, number>;
shows: { id: string; title: string; startsAtUtc: string | null }[];
} = $props();
let collapsed = $state(false);
let searchInput = $state<HTMLInputElement>();
const links = $derived([
{
href: '/sendungen',
label: 'Alle Streams',
icon: 'calendar-days',
count: counts.all,
active: !filterState
},
{
href: '/sendungen?state=created',
label: 'Angelegt',
icon: 'file',
count: counts.created,
active: filterState === 'created'
},
{
href: '/sendungen?state=preparing',
label: 'In Vorbereitung',
icon: 'pen',
count: counts.preparing,
active: filterState === 'preparing'
},
{
href: '/sendungen?state=ready',
label: 'Sendefertig',
icon: 'circle-check',
count: counts.ready,
active: filterState === 'ready'
},
{
href: '/sendungen?state=completed',
label: 'Abgeschlossen',
icon: 'box-archive',
count: counts.completed,
active: filterState === 'completed'
}
]);
onMount(() => {
collapsed = localStorage.getItem('sendewerk-stream-navigation') === 'collapsed';
});
function toggle() {
collapsed = !collapsed;
localStorage.setItem('sendewerk-stream-navigation', collapsed ? 'collapsed' : 'open');
}
async function revealSearch() {
if (collapsed) toggle();
await tick();
searchInput?.focus();
}
</script>
<aside class="fundus-navigation stream-navigation" class:collapsed aria-label="Streams-Navigation">
<header>
<div>
<p class="eyebrow">Planung</p>
<h1>Streams</h1>
</div>
<button
type="button"
onclick={toggle}
aria-label={collapsed ? 'Streams-Navigation ausklappen' : 'Streams-Navigation einklappen'}
aria-expanded={!collapsed}
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
>
</header>
{#if collapsed}
<button
class="collapsed-search-button"
type="button"
onclick={revealSearch}
aria-label="Streams durchsuchen"><AppIcon name="magnifying-glass" /></button
>
{:else}
<form class="context-search" method="GET" action={resolve('/sendungen')} role="search">
<label
><AppIcon name="magnifying-glass" /><span class="sr-only">Streams durchsuchen</span><input
bind:this={searchInput}
name="q"
type="search"
value={query}
placeholder="Streams durchsuchen"
/></label
>
</form>
{/if}
<nav>
{#each links as item (item.href)}
<a href={item.href} class:active={item.active} aria-current={item.active ? 'page' : undefined}
><AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong></a
>
{/each}
</nav>
{#if shows.length && !collapsed}
<section>
<h2>Geplant</h2>
<nav aria-label="Geplante Streams">
{#each shows as show (show.id)}
<a href={resolve('/sendungen/[id]', { id: show.id })}
><AppIcon name="calendar" /><span>{show.title}</span></a
>
{/each}
</nav>
</section>
{/if}
</aside>
-490
View File
@@ -1,490 +0,0 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import PresentationDisplay from './PresentationDisplay.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
usage,
revision,
sectionId,
sections,
error,
enhanceCommand,
open = false
}: {
usage: PieceUsage;
revision: number;
sectionId: string;
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
open?: boolean;
} = $props();
let formId = $derived(`usage-editor-form-${usage.id}`);
let editor: { showModal: () => void; close: () => void };
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let draft = $state(
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
let selectedSourceId = $state(
untrack(
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
)
);
onMount(() => {
if (open) editor.showModal();
});
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
function previewHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('\n', '<br>');
}
function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false;
editor.close();
}
function presentationSourceChanged(event: Event) {
selectedSourceId = (event.currentTarget as HTMLSelectElement).value;
const source = draft.sources.find((item) => item.id === selectedSourceId);
if (!source) return;
draft.presentationSourceLabel = source.label;
draft.presentationSourceUrl = source.url;
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
}
let previewContent = $derived({
mode: draft.presentationMode,
topics: draft.topics.map((topic) => topic.name),
title: draft.outputTitle || draft.title,
html: previewHtml(draft.presentation),
source: draft.presentationSourceUrl
? {
label: draft.presentationSourceLabel,
url: draft.presentationSourceUrl,
timecodeSeconds: draft.presentationSourceTimecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationMode === 'link' ? draft.presentationImageUrl : '',
siteName: draft.presentationSiteName,
provider: draft.presentationProvider,
providerId: draft.presentationProviderId,
embedUrl: '',
sources: [],
images: selectedMedia
? [{ id: selectedMedia.originalMediaId, altText: selectedMedia.altText }]
: []
});
let previewImageSrc = $derived(
selectedMedia && draft.presentationMode === 'text'
? '/media'
: selectedMedia && draft.presentationMode === 'image'
? `/media/${selectedMedia.originalMediaId}`
: ''
);
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
if (finish) await finish(result);
if (result.result.type === 'success') dirty = false;
};
};
</script>
<button
class="usage-edit-button"
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => editor.showModal()}
>
<NavIcon name="edit" />
</button>
<PieceEditorDialog
bind:this={editor}
bind:activeSection
labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose}
>
{#snippet header()}
<p class="eyebrow">Sendungsfassung</p>
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet}
<form
id={formId}
use:enhance={enhanceEditor}
method="POST"
action="?/updateUsage"
class="piece-dialog-form"
oninput={() => (dirty = true)}
onchange={() => (dirty = true)}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3>
</div>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
/>
<MarkdownEditorField
name="moderation"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
name="interaction"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interaction}
/>
<MarkdownEditorField
name="transition"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transition}
/>
</div>
<details class="piece-meta-details">
<summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid">
<label
>Vorbereitungsstatus<select
name="preparationStatus"
bind:value={draft.preparationStatus}
>
<option value="preparing">In Vorbereitung</option>
<option value="ready">Sendefertig</option>
</select></label
>
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
>
<option value={null}>Nicht angegeben</option>
<option value="low">Gering</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
</div>
</details>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h3>Recherche & Quellen</h3>
</div>
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="research"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.research}
/>
<MarkdownEditorField
name="ownNotes"
label="Interne Notizen zu dieser Verwendung"
placeholder="Absprachen und Hinweise nur für diese Sendung"
privateField
bind:value={draft.ownNotes}
/>
</div>
<section class="piece-source-summary">
<p class="eyebrow">Snapshot-Quellen</p>
{#if draft.sources.length}
{#each draft.sources as source (source.id)}<div>
<strong>{source.label}</strong><small>{source.url}</small>
</div>{/each}
{:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
</section>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Snapshot</p>
<h3>Werkzeuge</h3>
</div>
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
</div>
{#if draft.tools.length}<div class="source-list">
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
<input type="hidden" name="usageToolId" value={tool.id} />
<div class="tool-assignment-name">
<strong>{tool.name}</strong>{#if tool.startUrl}<small>{tool.startUrl}</small>{/if}
</div>
<label
>Arbeitslink<input
name="toolWorkUrl"
type="url"
maxlength="2000"
placeholder={tool.startUrl || 'https://…'}
bind:value={tool.workUrl}
/></label
>
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
>
</div>{/each}
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
<h3>Präsentation</h3>
</div>
<small>Private Recherche und Notizen werden nicht ausgegeben.</small>
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<p class="field-label">Präsentationsart</p>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentation" value={draft.presentation} />
{/if}
<input
type="hidden"
name="presentationSourceLabel"
value={draft.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<label class="wide-field"
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
bind:value={selectedSourceId}
onchange={presentationSourceChanged}
>
<option value="">Quelle auswählen</option>
{#each draft.sources as source (source.id)}
<option value={source.id}>{source.label || source.url}</option>
{/each}
</select></label
>
<label
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
{#if draft.presentationMode === 'link'}
<label
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}></textarea></label
>
</div>
{:else}
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
class="usage-media-picker"
>
<legend>Hochgeladenes Präsentationsbild</legend>
{#if draft.presentationMode === 'text'}<label
><input
type="radio"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
dirty = true;
}}
/> Kein Bild</label
>{/if}
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
type="radio"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
dirty = true;
}}
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
>Auswählen</span
></label
>{/each}
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
</aside>
</div>
</section>
</form>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button" type="submit" disabled={dirty}>Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" />
<label
><span class="sr-only">Ziel</span><select name="sectionId"
><option value="">Reservebank</option>{#each sections as target (target.id)}<option
value={target.id}
selected={target.id === sectionId}>{target.name}</option
>{/each}</select
></label
>
<button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}
>Aus Sendung entfernen</button
>
</form>
</div>
<div class="piece-dialog-primary-action">
{#if error}<span class="problem" role="alert">{error}</span>{/if}
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
</div>
{/snippet}
</PieceEditorDialog>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark';
const preferences: { value: Preference; label: string; icon: string }[] = [
{ value: 'light', label: 'Hell', icon: 'sun' },
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'display' }
];
let { displayName, role }: { displayName: string; role: 'barkeeper' | 'thekencrew' } = $props();
let preference = $state<Preference>('system');
function apply(next: Preference) {
preference = next;
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = next === 'system' ? (dark ? 'dark' : 'light') : next;
document.documentElement.dataset.themePreference = next;
localStorage.setItem('sendewerk-theme', next);
}
onMount(() => {
const stored = localStorage.getItem('sendewerk-theme');
preference = preferences.some((item) => item.value === stored)
? (stored as Preference)
: 'system';
const media = window.matchMedia('(prefers-color-scheme: dark)');
const followSystem = () => preference === 'system' && apply('system');
media.addEventListener('change', followSystem);
return () => media.removeEventListener('change', followSystem);
});
</script>
<details class="account-menu user-menu">
<summary aria-label={`Benutzermenü für ${displayName}`}>
<span aria-hidden="true">{displayName.slice(0, 1).toUpperCase()}</span>
</summary>
<div class="user-menu-popover">
<header>
<strong>{displayName}</strong>
<small>{role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</header>
{#if role === 'barkeeper'}
<a class="user-menu-link" href={resolve('/verwaltung')}>
<AppIcon name="gear" /><span>Einstellungen & Verwaltung</span>
</a>
{/if}
<fieldset>
<legend>Erscheinungsbild</legend>
<div class="theme-menu-options">
{#each preferences as item (item.value)}
<button
type="button"
class:active={preference === item.value}
aria-pressed={preference === item.value}
onclick={() => apply(item.value)}
>
<AppIcon name={item.icon} />
{item.label}
</button>
{/each}
</div>
</fieldset>
<form method="POST" action="/logout">
<button class="user-menu-link" type="submit"
><AppIcon name="right-from-bracket" /> Abmelden</button
>
</form>
</div>
</details>
+3 -3
View File
@@ -62,12 +62,12 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 20, migrations: 22,
pieces: 6, pieces: 6,
shows: 2, shows: 3,
usages: 5, usages: 5,
templates: 2, templates: 2,
reserve_banks: 2 reserve_banks: 3
}); });
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite')); expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
}); });
+83
View File
@@ -0,0 +1,83 @@
import { getTaxonomy, listPieces } from './content.js';
import { getPieceUsageContexts } from './shows.js';
export function fundusSearch(url: URL) {
const search = new URLSearchParams(url.searchParams);
for (const key of [...search.keys()]) if (key.startsWith('/')) search.delete(key);
const value = search.toString();
return value ? `?${value}` : '';
}
export function loadFundusWorkspace(url: URL) {
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1',
favorites: url.searchParams.get('favorites') === '1',
view: url.searchParams.get('view') ?? ''
};
const statuses =
filters.status === 'ready'
? (['ready'] as const)
: filters.status === 'preparing'
? (['preparing'] as const)
: (['preparing', 'ready'] as const);
let pieces = listPieces({
statuses: [...statuses],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
pieces = pieces.filter((piece) =>
filters.view === 'completed'
? usageContexts[piece.id]?.sentHistory.length > 0
: usageContexts[piece.id]?.sentHistory.length === 0
);
if (filters.favorites) pieces = pieces.filter((piece) => piece.isFavorite);
const taxonomy = getTaxonomy();
const allPieces = listPieces({ statuses: ['inbox', 'preparing', 'ready', 'archived'] });
const activePieces = allPieces.filter(
(piece) => piece.status === 'preparing' || piece.status === 'ready'
);
const allUsageContexts = getPieceUsageContexts(activePieces.map((piece) => piece.id));
const availablePieces = activePieces.filter(
(piece) => allUsageContexts[piece.id]?.sentHistory.length === 0
);
const deletedPieces = listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'],
deleted: true
});
return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy,
filters,
counts: {
all: availablePieces.length,
inbox: allPieces.filter((piece) => piece.status === 'inbox').length,
preparing: allPieces.filter((piece) => piece.status === 'preparing').length,
ready: allPieces.filter((piece) => piece.status === 'ready').length,
favorites: availablePieces.filter((piece) => piece.isFavorite).length,
completed: activePieces.filter((piece) => allUsageContexts[piece.id]?.sentHistory.length > 0)
.length,
archived: allPieces.filter((piece) => piece.status === 'archived').length,
deleted: deletedPieces.length,
topics: Object.fromEntries(
taxonomy.topics.map((topic) => [
topic.id,
allPieces.filter(
(piece) =>
(piece.status === 'preparing' || piece.status === 'ready') &&
piece.topics.some((item) => item.id === topic.id)
).length
])
)
}
};
}
+65
View File
@@ -0,0 +1,65 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import { searchWorkspace } from './global-search.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-search-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('global workspace search', () => {
it('finds private piece fields and keeps archived pieces discoverable', () => {
const privateMatch = searchWorkspace('Herstellerangaben');
expect(privateMatch.pieces).toEqual([
expect.objectContaining({ id: 'piece-console', matchLabel: 'Recherche' })
]);
getDatabase().prepare("UPDATE pieces SET status = 'archived' WHERE id = 'piece-console'").run();
const archivedMatch = searchWorkspace('Herstellerangaben');
expect(archivedMatch.pieces).toEqual([
expect.objectContaining({
id: 'piece-console',
status: 'archived',
href: '/archiv#piece-piece-console'
})
]);
});
it('requires at least two characters and groups stream snapshot matches separately', () => {
expect(searchWorkspace('x')).toEqual({ pieces: [], shows: [] });
const results = searchWorkspace('kleine Konsole');
expect(results.pieces.some((result) => result.id === 'piece-console')).toBe(true);
expect(results.shows.length).toBeGreaterThan(0);
expect(results.shows[0].href).toMatch(/^\/sendungen\//);
});
it('filters result types and piece taxonomy without leaking into stream matches', () => {
const pieces = searchWorkspace('kleine Konsole', {
type: 'pieces',
status: 'ready',
topicId: 'topic-gaming',
formatId: 'format-find'
});
expect(pieces.pieces.map((result) => result.id)).toEqual(['piece-console']);
expect(pieces.shows).toEqual([]);
expect(searchWorkspace('kleine Konsole', { topicId: 'topic-film' }).pieces).toEqual([]);
expect(searchWorkspace('kleine Konsole', { type: 'shows' }).pieces).toEqual([]);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { getDatabase } from './database.js';
import type { PieceStatus } from './content.js';
import type { ShowStatus } from './shows.js';
export type SearchResult = {
id: string;
title: string;
href: string;
status: PieceStatus | ShowStatus;
matchLabel: string;
excerpt: string;
};
export type GlobalSearchResults = {
pieces: SearchResult[];
shows: SearchResult[];
};
export type GlobalSearchFilters = {
type?: 'all' | 'pieces' | 'shows';
status?: string;
topicId?: string;
formatId?: string;
};
export type GlobalSearchFacets = {
topics: { id: string; name: string }[];
formats: { id: string; name: string }[];
};
type SearchField = { label: string; value: string };
function normalized(value: unknown): string {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function match(
fields: SearchField[],
query: string
): Pick<SearchResult, 'matchLabel' | 'excerpt'> | null {
for (const field of fields) {
const value = normalized(field.value);
const position = value.toLocaleLowerCase('de-DE').indexOf(query);
if (position === -1) continue;
const start = Math.max(0, position - 70);
const end = Math.min(value.length, position + query.length + 100);
return {
matchLabel: field.label,
excerpt: `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`
};
}
return null;
}
export function listGlobalSearchFacets(): GlobalSearchFacets {
const database = getDatabase();
return {
topics: database
.prepare('SELECT id, name FROM topics ORDER BY name COLLATE NOCASE')
.all() as GlobalSearchFacets['topics'],
formats: database
.prepare('SELECT id, name FROM formats ORDER BY name COLLATE NOCASE')
.all() as GlobalSearchFacets['formats']
};
}
export function searchWorkspace(
rawQuery: string,
filters: GlobalSearchFilters = {}
): GlobalSearchResults {
const query = normalized(rawQuery).toLocaleLowerCase('de-DE').slice(0, 200);
if (query.length < 2) return { pieces: [], shows: [] };
const database = getDatabase();
const pieces =
filters.type === 'shows'
? []
: (
database
.prepare(
`SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
p.format_id,
group_concat(DISTINCT pt.topic_id) AS topic_ids,
group_concat(DISTINCT COALESCE(ps.label, '') || ' ' || COALESCE(ps.url, '')) AS sources
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
LEFT JOIN piece_topics pt ON pt.piece_id = p.id
WHERE p.deleted_at_utc IS NULL
GROUP BY p.id ORDER BY p.updated_at_utc DESC`
)
.all() as Record<string, unknown>[]
)
.map((row): SearchResult | null => {
if (filters.status && row.status !== filters.status) return null;
if (filters.formatId && row.format_id !== filters.formatId) return null;
if (
filters.topicId &&
!String(row.topic_ids ?? '')
.split(',')
.includes(filters.topicId)
)
return null;
const found = match(
[
{ label: 'Titel', value: String(row.title) },
{ label: 'Ausgabetitel', value: String(row.output_title) },
{ label: 'Zusammenfassung', value: String(row.summary) },
{ label: 'Anmoderation', value: String(row.intro) },
{ label: 'Recherche', value: String(row.research_text) },
{ label: 'Moderation', value: String(row.moderation_notes) },
{ label: 'Interaktion', value: String(row.interaction_text) },
{ label: 'Übergang', value: String(row.transition_text) },
{ label: 'Präsentation', value: String(row.presentation_text) },
{ label: 'Quellen', value: String(row.sources) }
],
query
);
if (!found) return null;
const status = row.status as PieceStatus;
return {
id: String(row.id),
title: String(row.title),
href: status === 'archived' ? `/archiv#piece-${row.id}` : `/fundus/${row.id}`,
status,
...found
};
})
.filter((result): result is SearchResult => result !== null)
.slice(0, 50);
const shows =
filters.type === 'pieces' || filters.topicId || filters.formatId
? []
: (
database
.prepare(
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text,
group_concat(COALESCE(s.name, '') || ' ' || COALESCE(u.title_snapshot, '') || ' ' ||
COALESCE(u.output_title_snapshot, '') || ' ' || COALESCE(u.intro_snapshot, '') || ' ' ||
COALESCE(u.research_snapshot, '') || ' ' || COALESCE(u.moderation_snapshot, '') || ' ' ||
COALESCE(u.interaction_snapshot, '') || ' ' || COALESCE(u.transition_snapshot, '') || ' ' ||
COALESCE(u.presentation_snapshot, '') || ' ' || COALESCE(u.own_notes, ''), char(10)) AS contents
FROM shows sh
LEFT JOIN show_sections s ON s.show_id = sh.id
LEFT JOIN piece_usages u ON u.show_section_id = s.id
GROUP BY sh.id ORDER BY sh.starts_at_utc DESC, sh.created_at_utc DESC`
)
.all() as Record<string, unknown>[]
)
.map((row): SearchResult | null => {
if (filters.status && row.status !== filters.status) return null;
const found = match(
[
{ label: 'Titel', value: String(row.title) },
{ label: 'Twitch-Titel', value: String(row.twitch_title) },
{ label: 'Ankündigung', value: String(row.announcement_text) },
{ label: 'Ablauf und Pieces', value: String(row.contents) }
],
query
);
if (!found) return null;
return {
id: String(row.id),
title: String(row.title),
href: `/sendungen/${row.id}`,
status: row.status as ShowStatus,
...found
};
})
.filter((result): result is SearchResult => result !== null)
.slice(0, 50);
return { pieces, shows };
}
+2
View File
@@ -52,6 +52,7 @@ export type LiveView = {
show: { show: {
id: string; id: string;
title: string; title: string;
startsAtUtc: string | null;
status: string; status: string;
revision: number; revision: number;
totalDurationSeconds: number; totalDurationSeconds: number;
@@ -531,6 +532,7 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
show: { show: {
id: show.id, id: show.id,
title: show.title, title: show.title,
startsAtUtc: show.startsAtUtc,
status: show.status, status: show.status,
revision: show.revision, revision: show.revision,
totalDurationSeconds: show.totalDurationSeconds totalDurationSeconds: show.totalDurationSeconds
+13 -11
View File
@@ -59,7 +59,7 @@ const tools = [
{ {
name: 'list_pieces', name: 'list_pieces',
title: 'Pieces durchsuchen', title: 'Pieces durchsuchen',
description: 'Listet und durchsucht redaktionelle Pieces im Sendewerk-Fundus.', description: 'Listet und durchsucht redaktionelle Pieces in Sendewerk.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -108,8 +108,8 @@ const tools = [
}, },
{ {
name: 'list_shows', name: 'list_shows',
title: 'Sendungen auflisten', title: 'Streams auflisten',
description: 'Listet Sendungen mit Status, Termin, Dauer und Abschnittszahl.', description: 'Listet Streams mit Status, Termin, Dauer und Abschnittszahl.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -138,8 +138,8 @@ const tools = [
}, },
{ {
name: 'get_show_rundown', name: 'get_show_rundown',
title: 'Sendungsablauf lesen', title: 'Streamablauf lesen',
description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve einer Sendung.', description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve eines Streams.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } }, properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
@@ -265,9 +265,11 @@ function callTool(name: string, input: unknown, user: AuthUser) {
const offset = integer(input.offset, 0, 0); const offset = integer(input.offset, 0, 0);
const all = listPieces({ statuses, query }); const all = listPieces({ statuses, query });
return toolResult({ return toolResult({
pieces: all pieces: all.slice(offset, offset + limit).map(({ mediaId, formatId, ...piece }) => {
.slice(offset, offset + limit) void mediaId;
.map(({ mediaId: _mediaId, formatId: _formatId, ...piece }) => piece), void formatId;
return piece;
}),
pagination: { limit, offset, total: all.length } pagination: { limit, offset, total: all.length }
}); });
} }
@@ -315,7 +317,7 @@ function callTool(name: string, input: unknown, user: AuthUser) {
case 'get_show_rundown': { case 'get_show_rundown': {
exactKeys(input, ['id']); exactKeys(input, ['id']);
const show = getShow(stringArgument(input.id, 100, true)); const show = getShow(stringArgument(input.id, 100, true));
if (!show) return toolError('Sendung nicht gefunden.'); if (!show) return toolError('Stream nicht gefunden.');
return toolResult({ return toolResult({
id: show.id, id: show.id,
title: show.title, title: show.title,
@@ -373,7 +375,7 @@ export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
_meta: { _meta: {
'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' } 'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' }
}, },
instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.', instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.',
ttlMs: 3_600_000, ttlMs: 3_600_000,
cacheScope: 'private' cacheScope: 'private'
} }
@@ -392,7 +394,7 @@ export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
protocolVersion, protocolVersion,
capabilities: { tools: { listChanged: false } }, capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'Sendewerk', version: '0.7.3' }, serverInfo: { name: 'Sendewerk', version: '0.7.3' },
instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.' instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.'
} }
}; };
} }
+2
View File
@@ -109,6 +109,8 @@ export function registerOAuthClient(input: unknown): OAuthClient {
if ( if (
!name || !name ||
name.length > 120 || name.length > 120 ||
// OAuth client names must not contain control or bidi-control characters.
// eslint-disable-next-line no-control-regex
/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name) || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name) ||
!redirectUris.length || !redirectUris.length ||
redirectUris.length > 10 redirectUris.length > 10
+45
View File
@@ -0,0 +1,45 @@
/**
* Provider-neutral contract for a future Post-production assistant.
*
* This module deliberately performs no network request. A future adapter must
* require explicit user approval, identify the fields it proposes to change and
* return suggestions separately from persisted editorial data.
*/
export type PostAnalysisRequest = {
show: { id: string; title: string; startsAtUtc: string | null };
sources: {
id: string;
originalName: string;
recordingPartId: string | null;
content: string;
}[];
rules: {
materialIsAuthoritative: true;
preserveExactQuotes: true;
inventStatements: false;
considerLongStorytimes: true;
fixedCandidateQuota: false;
};
};
export type PostSuggestion = {
id: string;
kind: 'clip-candidate' | 'youtube-copy' | 'thumbnail-idea' | 'highlight-candidate';
sourceFileId: string;
startSeconds: number | null;
endSeconds: number | null;
exactQuote: string;
rationale: string;
fields: Record<string, string>;
};
export type PostAnalysisResult = {
provider: string;
model: string;
createdAtUtc: string;
suggestions: PostSuggestion[];
};
export interface PostAnalysisAdapter {
analyze(request: PostAnalysisRequest): Promise<PostAnalysisResult>;
}
+189
View File
@@ -0,0 +1,189 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
createPostItem,
createRecordingPart,
getExport,
getPostWorkspace,
getThumbnailFile,
importPostSource,
listPostPieceOptions,
listPostShows,
savePostItem,
uploadThumbnail
} from './post.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-post-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
getDatabase()
.prepare(
`UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = revision + 1
WHERE id = 'show-october'`
)
.run('2026-10-24T00:00:00.000Z');
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('Postproduktion', () => {
it('projects every completed show even before post-production starts', () => {
expect(listPostShows()).toContainEqual(
expect.objectContaining({
showId: 'show-october',
sourceCount: 0,
youtubeCount: 0,
clipCount: 0,
highlightCount: 0,
progress: 'Noch nicht begonnen'
})
);
});
it('keeps imported Markdown exact, extracts only present timecodes and rejects duplicates', async () => {
const content =
'# Kontext\n\n[00:01:12] Exaktes Zitat\n01:15:03 – Später Moment\nOhne Zeitmarke';
const sourceId = await importPostSource(
'show-october',
null,
new File([content], 'heinzlive-teil-1.md', { type: 'text/markdown' })
);
const source = getPostWorkspace('show-october').sources.find((item) => item.id === sourceId)!;
expect(source.timecodes).toEqual([
{ seconds: 72, label: 'Exaktes Zitat', line: 3 },
{ seconds: 4503, label: 'Später Moment', line: 4 }
]);
expect(source.transcriptText).toBe('Exaktes Zitat\nSpäter Moment');
await expect(
importPostSource(
'show-october',
null,
new File([content], 'anderer-name.md', { type: 'text/markdown' })
)
).rejects.toThrow('DUPLICATE_SOURCE');
});
it('defaults the only context source and offers only pieces used in the completed stream', async () => {
const sourceId = await importPostSource(
'show-october',
null,
new File(['# Kontext'], 'kontext.md', { type: 'text/markdown' })
);
const clipId = createPostItem('show-october', 'clip', 'Schneller Clip');
expect(
getPostWorkspace('show-october').items.find((item) => item.id === clipId)?.sourceFileId
).toBe(sourceId);
const pieces = listPostPieceOptions('show-october');
expect(pieces.length).toBeGreaterThan(0);
expect(pieces.every((piece) => piece.id !== 'piece-quiz')).toBe(true);
});
it('stores independent video, clip and highlight records with revision protection', () => {
const youtubeId = createPostItem('show-october', 'youtube', 'Uncut Teil 1');
const clipId = createPostItem('show-october', 'clip', 'Linux ist keine Religion');
const highlightId = createPostItem('show-october', 'highlight', 'Die Diskussion über Linux');
savePostItem('show-october', youtubeId, 1, {
title: 'Uncut Teil 1',
status: 'review',
description: '00:00 Begrüßung',
titleVariants: ['Variante A', 'Variante B'],
publicationStatus: 'unpublished',
videoFormat: '16:9'
});
savePostItem('show-october', clipId, 1, {
title: 'Linux ist keine Religion',
status: 'editing',
clipType: 'opinion',
originalTranscript: 'Das wurde genau so gesagt.',
startSeconds: 72,
endSeconds: 103,
primaryHook: 'Linux ist keine Religion!',
socialCaption: 'Gemeinsamer Text\n\n#linux #heinzlive',
videoFormat: '9:16',
targetTikTok: true,
targetInstagram: true,
targetShorts: true
});
savePostItem('show-october', highlightId, 1, {
title: 'Die Diskussion über Linux',
status: 'draft',
description: 'Ein längerer Ausschnitt.',
startSeconds: 60,
endSeconds: 360,
videoFormat: '16:9'
});
expect(() =>
savePostItem('show-october', clipId, 1, {
title: 'Veraltete Änderung',
status: 'done'
})
).toThrow('CONFLICT');
const workspace = getPostWorkspace('show-october');
expect(workspace.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: youtubeId, description: '00:00 Begrüßung' }),
expect.objectContaining({
id: clipId,
originalTranscript: 'Das wurde genau so gesagt.',
socialCaption: 'Gemeinsamer Text\n\n#linux #heinzlive',
clipType: 'opinion'
}),
expect.objectContaining({ id: highlightId, endSeconds: 360 })
])
);
});
it('stores real thumbnail files and only emits technically ready exports', async () => {
const youtubeId = createPostItem('show-october', 'youtube', 'Uncut');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const thumbnailId = await uploadThumbnail(
'show-october',
youtubeId,
1,
new File([png], 'thumb.png', { type: 'image/png' }),
'Ein fertiges Thumbnail',
true
);
const thumbnail = await getThumbnailFile('show-october', thumbnailId);
expect(thumbnail?.data).toEqual(png);
const partId = createRecordingPart('show-october', {
label: 'Teil 1',
mediaFilePath: '/media/stream-teil-1.mov',
frameRate: 25,
timebase: 25
});
const clipId = createPostItem('show-october', 'clip', 'Exportclip');
expect(() => getExport('show-october', clipId, 'resolve')).toThrow('EXPORT_NOT_READY');
savePostItem('show-october', clipId, 1, {
title: 'Exportclip',
status: 'done',
recordingPartId: partId,
startSeconds: 10,
endSeconds: 20,
videoFormat: '9:16',
subtitleSrt: '1\n00:00:00,000 --> 00:00:02,000\nOriginaltext'
});
expect(getExport('show-october', clipId, 'resolve').body).toContain(
'<pathurl>file:///media/stream-teil-1.mov</pathurl>'
);
expect(getExport('show-october', clipId, 'srt').body).toContain('Originaltext');
});
});
File diff suppressed because it is too large. Load diff
+56 -1
View File
@@ -21,6 +21,7 @@ import {
removePieceUsage, removePieceUsage,
removeShowSection, removeShowSection,
updatePieceUsage, updatePieceUsage,
updatePieceUsageMetadata,
updateShowSection, updateShowSection,
type PieceUsage type PieceUsage
} from './shows.js'; } from './shows.js';
@@ -181,7 +182,7 @@ describe('show builder data layer', () => {
const blank = createShow({ title: null, startsAtUtc: null, templateId: null }); const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
expect(getShow(blank.id)).toMatchObject({ expect(getShow(blank.id)).toMatchObject({
title: 'Neue Sendung', title: 'Neuer Stream',
startsAtUtc: null, startsAtUtc: null,
sections: [], sections: [],
reserve: [] reserve: []
@@ -270,6 +271,60 @@ describe('show builder data layer', () => {
}); });
}); });
it('updates usage metadata without changing editorial or presentation snapshots', () => {
const created = createShow({ title: 'Schnelländerung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Fundstücke',
categoryId: 'cat-reaction',
explicitDurationSeconds: null
});
const added = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const before = getShow(created.id)!.sections[0].usages[0];
updatePieceUsageMetadata(created.id, added.id, added.revision, {
title: 'Konsole im Inspector',
outputTitle: 'Konsole live',
durationSeconds: 600,
ownNotes: 'Direkt im Streamplan geändert',
preparationStatus: 'ready',
preparationEffort: 'low'
});
const after = getShow(created.id)!.sections[0].usages[0];
expect(after).toMatchObject({
title: 'Konsole im Inspector',
outputTitle: 'Konsole live',
durationSeconds: 600,
ownNotes: 'Direkt im Streamplan geändert',
preparationStatus: 'ready',
preparationEffort: 'low'
});
expect({
intro: after.intro,
research: after.research,
moderation: after.moderation,
interaction: after.interaction,
transition: after.transition,
presentation: after.presentation,
presentationMode: after.presentationMode,
presentationMediaId: after.presentationMediaId,
tools: after.tools
}).toEqual({
intro: before.intro,
research: before.research,
moderation: before.moderation,
interaction: before.interaction,
transition: before.transition,
presentation: before.presentation,
presentationMode: before.presentationMode,
presentationMediaId: before.presentationMediaId,
tools: before.tools
});
});
it('derives sent history and current planning only from qualifying usages by original id', () => { it('derives sent history and current planning only from qualifying usages by original id', () => {
const completed = createShow({ const completed = createShow({
title: 'Abgeschlossene Sendung', title: 'Abgeschlossene Sendung',
+48 -1
View File
@@ -196,6 +196,16 @@ export type PieceUsageUpdate = {
presentationMediaId?: string | null; presentationMediaId?: string | null;
}; };
export type PieceUsageMetadataUpdate = Pick<
PieceUsageUpdate,
| 'title'
| 'outputTitle'
| 'durationSeconds'
| 'ownNotes'
| 'preparationStatus'
| 'preparationEffort'
>;
export type MovePieceUsageInput = { export type MovePieceUsageInput = {
sectionId: string | null; sectionId: string | null;
position: number; position: number;
@@ -1021,7 +1031,7 @@ export function getPieceUsageContexts(
export function createShow(input: CreateShowInput): CreatedMutationResult { export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase(); const database = getDatabase();
const id = randomUUID(); const id = randomUUID();
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true); const title = cleanText(input.title ?? 'Neuer Stream', 200, 'title', true);
const startsAtUtc = validTimestamp(input.startsAtUtc); const startsAtUtc = validTimestamp(input.startsAtUtc);
const now = new Date().toISOString(); const now = new Date().toISOString();
const create = database.transaction(() => { const create = database.transaction(() => {
@@ -1889,6 +1899,43 @@ export function updatePieceUsage(
})(); })();
} }
export function updatePieceUsageMetadata(
showId: string,
usageId: string,
expectedRevision: number,
input: PieceUsageMetadataUpdate
): MutationResult {
const show = getShow(showId);
const usage = [
...(show?.sections.flatMap((section) => section.usages) ?? []),
...(show?.reserve ?? [])
].find((item) => item.id === usageId);
if (!usage) throw new Error('NOT_FOUND');
return updatePieceUsage(showId, usageId, expectedRevision, {
...input,
intro: usage.intro,
research: usage.research,
moderation: usage.moderation,
interaction: usage.interaction,
transition: usage.transition,
presentation: usage.presentation,
presentationMode: usage.presentationMode,
presentationSourceLabel: usage.presentationSourceLabel,
presentationSourceUrl: usage.presentationSourceUrl,
presentationSourceTimecodeSeconds: usage.presentationSourceTimecodeSeconds,
presentationTitle: usage.presentationTitle,
presentationDescription: usage.presentationDescription,
presentationImageUrl: usage.presentationImageUrl,
presentationSiteName: usage.presentationSiteName,
presentationMediaId: usage.presentationMediaId,
tools: usage.tools.map((tool) => ({
id: tool.id,
workUrl: tool.workUrl,
note: tool.note
}))
});
}
export function removePieceUsage( export function removePieceUsage(
showId: string, showId: string,
usageId: string, usageId: string,
+1 -2
View File
@@ -7,6 +7,5 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({
: locals.user, : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'), presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'), guestMode: url.pathname.startsWith('/freigabe/'),
oauthMode: url.pathname === '/oauth/authorize', oauthMode: url.pathname === '/oauth/authorize'
liveControllerMode: url.pathname === '/live'
}); });
+51 -48
View File
@@ -5,22 +5,22 @@
import NavIcon from '#lib/components/NavIcon.svelte'; import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import UserMenu from '#lib/components/UserMenu.svelte';
let { children, data } = $props(); let { children, data } = $props();
const primaryNavigation = [ const primaryNavigation = [
{ href: '/', label: 'Start', icon: 'house' },
{ href: '/eingang', label: 'Eingang', icon: 'inbox' }, { href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' }, { href: '/fundus', label: 'Pieces', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' }, { href: '/sendungen', label: 'Streams', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' } { href: '/live', label: 'Live', icon: 'tower-broadcast' },
{ href: '/post', label: 'Post', icon: 'clapperboard' }
] as const; ] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) { function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
return ( return (
page.url.pathname.startsWith(href) || (href === '/sendungen' && page.url.pathname === '/live') page.url.pathname.startsWith(href) || (href === '/fundus' && page.url.pathname === '/archiv')
); );
} }
</script> </script>
@@ -28,7 +28,7 @@
<svelte:head> <svelte:head>
<meta <meta
name="description" name="description"
content="Sendewerk organisiert redaktionelle Inhalte und heinzlive-Sendungen." content="Sendewerk organisiert redaktionelle Pieces, heinzlive-Streams und Postproduktion."
/> />
</svelte:head> </svelte:head>
@@ -37,22 +37,22 @@
{:else if data.guestMode || data.oauthMode} {:else if data.guestMode || data.oauthMode}
<div class="guest-shell"> <div class="guest-shell">
<header class="auth-header"> <header class="auth-header">
<span class="brand" <span class="brand">
><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
>Sendewerk</span <span class="brand-name">Sendewerk</span>
></span <small class="brand-version">0.9.0</small>
><ThemeToggle /> </span>
<ThemeToggle />
</header> </header>
{@render children()} {@render children()}
</div> </div>
{:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user} {:else if data.user}
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <header class="app-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite"> <a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span> <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span> <span class="brand-name">Sendewerk</span>
<small class="brand-version">0.9.0</small>
</a> </a>
<nav class="main-navigation" aria-label="Hauptnavigation"> <nav class="main-navigation" aria-label="Hauptnavigation">
@@ -61,6 +61,7 @@
href={resolve(item.href)} href={resolve(item.href)}
class:active={isActive(item.href)} class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined} aria-current={isActive(item.href) ? 'page' : undefined}
aria-label={item.label}
> >
<NavIcon name={item.icon} /> <NavIcon name={item.icon} />
<span>{item.label}</span> <span>{item.label}</span>
@@ -68,47 +69,48 @@
{/each} {/each}
</nav> </nav>
<div class="sidebar-footer"> <div class="app-header-actions">
{#if data.user.role === 'barkeeper'} <form class="global-search-form" method="GET" action={resolve('/suche')} role="search">
<nav class="utility-navigation" aria-label="Verwaltung"> <label>
<a <AppIcon name="magnifying-glass" />
href={resolve(administration.href)} <span class="sr-only">Pieces und Streams durchsuchen</span>
class:active={isActive(administration.href)} <input
aria-current={isActive(administration.href) ? 'page' : undefined} name="q"
> type="search"
<NavIcon name={administration.icon} /> value={page.url.pathname === '/suche' ? (page.url.searchParams.get('q') ?? '') : ''}
<span>{administration.label}</span> placeholder="Pieces und Streams suchen"
</a> />
</nav> </label>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
</form> </form>
<ThemeToggle /> <UserMenu displayName={data.user.displayName} role={data.user.role} />
</div> </div>
</aside> </header>
<header class="mobile-header"> <header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite"> <a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span> <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span> <span class="brand-name">Sendewerk</span>
<small class="brand-version">0.9.0</small>
</a> </a>
<form method="POST" action="/logout"> <form
<button class="mobile-logout" type="submit" aria-label="Abmelden" class="global-search-form mobile-global-search"
><AppIcon name="right-from-bracket" /></button method="GET"
action={resolve('/suche')}
role="search"
> >
<label>
<AppIcon name="magnifying-glass" />
<span class="sr-only">Pieces und Streams durchsuchen</span>
<input name="q" type="search" placeholder="Suchen" />
</label>
</form> </form>
<ThemeToggle /> <UserMenu displayName={data.user.displayName} role={data.user.role} />
</header> </header>
<main id="main-content">{@render children()}</main> <main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation"> <nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)} {#each primaryNavigation as item (item.href)}
<a <a
href={resolve(item.href)} href={resolve(item.href)}
class:active={isActive(item.href)} class:active={isActive(item.href)}
@@ -125,7 +127,8 @@
<header class="auth-header"> <header class="auth-header">
<a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung"> <a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span> <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span> <span class="brand-name">Sendewerk</span>
<small class="brand-version">0.9.0</small>
</a> </a>
<ThemeToggle /> <ThemeToggle />
</header> </header>
+32 -1
View File
@@ -1,7 +1,38 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js'; import { hasUsers } from '#lib/server/auth.js';
import { listPieces } from '#lib/server/content.js';
import { getPieceUsageContexts, getShow, listShows } from '#lib/server/shows.js';
export function load({ locals }) { export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup'); if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login'); if (!locals.user) redirect(307, '/login');
const now = new Date().toISOString();
const activeShows = listShows(['draft', 'ready', 'live']);
const orderedShows = [...activeShows].sort((left, right) => {
if (left.status === 'live' && right.status !== 'live') return -1;
if (right.status === 'live' && left.status !== 'live') return 1;
if (left.startsAtUtc && right.startsAtUtc) {
const leftPast = left.startsAtUtc < now;
const rightPast = right.startsAtUtc < now;
if (leftPast !== rightPast) return leftPast ? 1 : -1;
return leftPast
? right.startsAtUtc.localeCompare(left.startsAtUtc)
: left.startsAtUtc.localeCompare(right.startsAtUtc);
}
return left.startsAtUtc ? -1 : right.startsAtUtc ? 1 : 0;
});
const nextShowSummary = orderedShows[0] ?? null;
const nextShow = nextShowSummary ? getShow(nextShowSummary.id) : null;
const pieceCandidates = listPieces({ statuses: ['preparing', 'ready'] });
const contexts = getPieceUsageContexts(pieceCandidates.map((piece) => piece.id));
const currentPieces = pieceCandidates
.filter((piece) => !(contexts[piece.id]?.sentHistory.length > 0) || piece.isFavorite)
.slice(0, 6);
return {
nextShow,
currentPieces,
upcomingShows: orderedShows.filter((show) => show.id !== nextShowSummary?.id).slice(0, 4)
};
} }
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import { formatDuration } from '#lib/format.js';
let { data } = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Sendefertig';
if (status === 'live') return 'Live';
return 'In Vorbereitung';
}
</script>
<svelte:head><title>Start | Sendewerk</title></svelte:head>
<section class="page-wrap dashboard-page">
<header class="dashboard-heading">
<div>
<p class="eyebrow">Redaktion</p>
<h1>Was als Nächstes ansteht</h1>
</div>
<nav class="dashboard-quick-actions" aria-label="Schnellaktionen">
<a class="secondary-button" href={resolve('/fundus?create=1')}
><AppIcon name="plus" /> Neues Piece</a
>
<a class="primary-button" href={resolve('/sendungen?create=1')}
><AppIcon name="calendar-plus" /> Neuen Stream planen</a
>
</nav>
</header>
<div class="dashboard-grid">
<section class="dashboard-next-stream" aria-labelledby="next-stream-heading">
<header>
<div>
<p class="eyebrow">Nächster Stream</p>
<h2 id="next-stream-heading">{data.nextShow?.title ?? 'Noch kein Stream geplant'}</h2>
{#if data.nextShow}<p>
{berlinDate(data.nextShow.startsAtUtc)} Uhr · {statusLabel(data.nextShow.status)}
</p>{/if}
</div>
{#if data.nextShow}<a
class="text-link"
href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
>Planung öffnen <AppIcon name="arrow-right" /></a
>{/if}
</header>
{#if data.nextShow}
<div class="dashboard-run-list">
{#each data.nextShow.sections as section (section.id)}
<div class="dashboard-run-section">
<span class="run-marker" style:--marker={section.categoryColor ?? 'var(--gold)'}
></span>
<div>
<strong>{section.name}</strong>
<small>{formatDuration(section.durationSeconds)}</small>
{#if section.usages.length}<p>
{section.usages.map((usage) => usage.title).join(' · ')}
</p>{/if}
</div>
</div>
{/each}
{#if !data.nextShow.sections.length}<p class="empty-state">
Der Ablauf ist noch leer.
</p>{/if}
</div>
<div class="dashboard-stream-actions">
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
><AppIcon name="pen" /> Ablauf bearbeiten</a
>
<a
class="primary-button"
href={resolve(`/live?show=${encodeURIComponent(data.nextShow.id)}`)}
><AppIcon name="tower-broadcast" /> Live öffnen</a
>
</div>
{:else}
<div class="empty-state compact">
<p>Lege einen Stream an, um hier den nächsten Ablauf zu sehen.</p>
</div>
{/if}
</section>
<aside class="dashboard-side-stack">
<section aria-labelledby="upcoming-heading">
<header>
<h2 id="upcoming-heading">Weitere Streams</h2>
<a href={resolve('/sendungen')}>Alle</a>
</header>
{#if data.upcomingShows.length}
<div class="dashboard-mini-list">
{#each data.upcomingShows as show (show.id)}
<a href={resolve('/sendungen/[id]', { id: show.id })}
><span
><strong>{show.title}</strong><small>{berlinDate(show.startsAtUtc)} Uhr</small
></span
><AppIcon name="chevron-right" /></a
>
{/each}
</div>
{:else}<p class="empty-state compact">Keine weiteren Streams geplant.</p>{/if}
</section>
</aside>
</div>
<section class="dashboard-pieces" aria-labelledby="current-pieces-heading">
<header>
<div>
<p class="eyebrow">Pieces</p>
<h2 id="current-pieces-heading">Aktuell in Arbeit</h2>
</div>
<a href={resolve('/fundus')}>Alle Pieces <AppIcon name="arrow-right" /></a>
</header>
{#if data.currentPieces.length}
<div class="dashboard-piece-grid">
{#each data.currentPieces as piece (piece.id)}
<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span class={`show-state ${piece.status}`}
>{piece.status === 'ready' ? 'Bereit' : 'In Vorbereitung'}</span
>
<strong>{piece.title}</strong>
<p>{piece.summary || piece.format}</p>
</a>
{/each}
</div>
{:else}<p class="empty-state">Keine aktuellen Pieces.</p>{/if}
</section>
</section>
+3 -3
View File
@@ -76,15 +76,15 @@ export const GET: RequestHandler = () =>
} }
}, },
'/shows': { '/shows': {
get: { summary: 'Sendungen lesen', responses: ok }, get: { summary: 'Streams lesen', responses: ok },
post: { post: {
summary: 'Sendung anlegen', summary: 'Stream anlegen',
parameters: [idempotency], parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }), requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
responses: { '201': { description: 'Angelegt' } } responses: { '201': { description: 'Angelegt' } }
} }
}, },
'/shows/{id}': { get: { summary: 'Sendung lesen', parameters: [id], responses: ok } }, '/shows/{id}': { get: { summary: 'Stream lesen', parameters: [id], responses: ok } },
'/shows/{id}/usages': { '/shows/{id}/usages': {
post: { post: {
summary: 'Piece einplanen', summary: 'Piece einplanen',
+1 -1
View File
@@ -50,7 +50,7 @@ export const actions = {
redirect(303, `/sendungen/${duplicate.id}`); redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) { } catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause; if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' }); return fail(400, { error: 'Der Stream konnte nicht dupliziert werden.' });
} }
} }
} satisfies Actions; } satisfies Actions;
+6 -25
View File
@@ -1,41 +1,22 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head> <svelte:head><title>Archivierte Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Archiv</h1> <p class="eyebrow">Pieces</p>
<p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p> <h1>Archiviert und gelöscht</h1>
<p class="page-intro">Bewusst abgelegte Pieces bleiben auffindbar und wiederherstellbar.</p>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2> <h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list"> {#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article> {#each data.pieces as piece (piece.id)}<article id={`piece-${piece.id}`}>
<div> <div>
<h2>{piece.title}</h2> <h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p> <p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
@@ -57,7 +38,7 @@
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title"> <section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2> <h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy"> <p class="quiet-copy">
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert. Diese Einträge bleiben wiederherstellbar; bestehende Streamfassungen werden nicht verändert.
</p> </p>
{#if data.deletedPieces.length}<div class="archive-list"> {#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article> {#each data.deletedPieces as piece (piece.id)}<article>
+41 -1
View File
@@ -1,7 +1,14 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { captureInbox, getTaxonomy, listPieces, setPieceDeleted } from '#lib/server/content.js'; import {
captureInbox,
getPiece,
getTaxonomy,
listPieces,
savePiece,
setPieceDeleted
} from '#lib/server/content.js';
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js'; import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
@@ -75,5 +82,38 @@ export const actions = {
intent: 'feedDiscard', intent: 'feedDiscard',
feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.`
}; };
},
quickSaveInbox: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const id = String(data.get('pieceId') || '');
const piece = getPiece(id);
if (!piece || piece.status !== 'inbox')
return fail(404, {
intent: 'quickSaveInbox',
error: 'Der Eingangseintrag wurde nicht gefunden.'
});
try {
const saved = savePiece(id, Number(data.get('revision')), {
...piece,
status: 'inbox',
title: String(data.get('title') || ''),
summary: String(data.get('summary') || ''),
topicIds: piece.topicIds,
categoryIds: piece.categoryIds,
sources: piece.sources,
tools: piece.tools.map((tool) => ({
toolId: tool.toolId,
workUrl: tool.workUrl,
note: tool.note
}))
});
return { intent: 'quickSaveInbox', saved: true, revision: saved.revision };
} catch {
return fail(409, {
intent: 'quickSaveInbox',
error: 'Der Eingangseintrag wurde inzwischen geändert. Bitte neu laden.'
});
}
} }
} satisfies Actions; } satisfies Actions;
+177 -74
View File
@@ -1,32 +1,47 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte'; import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let rssDialog: { showModal: () => void }; let rssDialog: { showModal: () => void };
let selectedKey = $state(
untrack(() =>
data.feedEntries[0]
? `feed:${data.feedEntries[0].id}`
: data.pieces[0]
? `piece:${data.pieces[0].id}`
: ''
)
);
let selectedFeed = $derived(
data.feedEntries.find((entry) => selectedKey === `feed:${entry.id}`) ?? null
);
let selectedPiece = $derived(
data.pieces.find((piece) => selectedKey === `piece:${piece.id}`) ?? null
);
</script> </script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head> <svelte:head><title>Eingang | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="unified-workspace inbox-workspace">
<header class="page-heading"> <aside class="workspace-navigation" aria-label="Eingangsquellen">
<h1>Eingang</h1> <header>
<p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
</header>
<section class="quiet-panel capture-panel">
<div> <div>
<p class="eyebrow">Schnellaufnahme</p> <p class="eyebrow">Sichtung</p>
<h2>Was soll nicht verloren gehen?</h2> <h1>Eingang</h1>
</div> </div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} <span>{data.pieces.length + data.feedEntries.length}</span>
</header>
<section class="capture-panel">
<h2>Schnellaufnahme</h2>
{#if form?.captured}<div class="form-message success" role="status"> {#if form?.captured}<div class="form-message success" role="status">
Im Eingang gespeichert. Im Eingang gespeichert.
<a href={resolve('/fundus/[id]', { id: form.capturedId })}>Jetzt ausarbeiten</a>
</div>{/if} </div>{/if}
<form method="POST" action="?/capture" class="capture-form"> <form method="POST" action="?/capture" class="capture-form">
<label <label
>Gedanke <span>optional bei URL</span><input >Gedanke<input
name="title" name="title"
maxlength="200" maxlength="200"
value={form?.title ?? ''} value={form?.title ?? ''}
@@ -45,89 +60,177 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button> <button class="primary-button" type="submit">Im Eingang ablegen</button>
</form> </form>
</section> </section>
<section class="quiet-panel rss-inbox-panel"> <section class="workspace-navigation-group rss-inbox-panel">
<div class="panel-heading"> <h2>Quellen</h2>
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2>RSS-Beiträge</h2>
</div>
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
</div>
<p class="catalog-note"> <p class="catalog-note">
{data.feedEntries.length} {data.feedEntries.length}
{data.feedEntries.length === 1 ? 'Beitrag ist' : 'Beiträge sind'} {data.feedEntries.length === 1 ? 'Beitrag ist' : 'Beiträge sind'} verfügbar.
verfügbar. Erst deine ausdrückliche Auswahl übernimmt sie in den redaktionellen Eingang.
</p> </p>
<button
class:active={selectedKey.startsWith('feed:')}
type="button"
onclick={() => data.feedEntries[0] && (selectedKey = `feed:${data.feedEntries[0].id}`)}
><AppIcon name="rss" /><span>RSS-Beiträge</span><b>{data.feedEntries.length}</b></button
>
<button
class:active={selectedKey.startsWith('piece:')}
type="button"
onclick={() => data.pieces[0] && (selectedKey = `piece:${data.pieces[0].id}`)}
><AppIcon name="inbox" /><span>Redaktioneller Eingang</span><b>{data.pieces.length}</b
></button
>
</section>
<button
class="secondary-button"
type="button"
disabled={!data.feedEntries.length}
onclick={() => rssDialog.showModal()}><AppIcon name="list" /> RSS-Beiträge auswählen</button
>
</aside>
<main class="workspace-content-pane inbox-catalog">
<header>
<div>
<p class="eyebrow">Noch nicht übernommen</p>
<h2>Neue Beiträge und Gedanken</h2>
</div>
</header>
<div class="inbox-catalog-content">
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if} {#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status"> {#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess} {form.feedSuccess}
</div>{/if} </div>{/if}
<button <section class="inbox-catalog-group">
class="compact-add-button" <h3>RSS</h3>
{#each data.feedEntries as entry (entry.id)}<button
class="inbox-catalog-row"
class:active={selectedKey === `feed:${entry.id}`}
type="button" type="button"
disabled={!data.feedEntries.length} onclick={() => (selectedKey = `feed:${entry.id}`)}
onclick={() => rssDialog.showModal()} ><span><small>{entry.feedName}</small><strong>{entry.title}</strong></span><AppIcon
> name="chevron-right"
<AppIcon name="list" /> /></button
{data.feedEntries.length ? 'RSS-Beiträge auswählen' : 'Keine neuen RSS-Beiträge'} >{:else}<p class="empty-copy">Keine neuen RSS-Beiträge.</p>{/each}
</button>
</section> </section>
<RssSelectionDialog <section class="inbox-catalog-group">
bind:this={rssDialog} <h3>Redaktioneller Eingang</h3>
entries={data.feedEntries} {#each data.pieces as piece (piece.id)}<button
categories={data.taxonomy.categories} class="inbox-catalog-row"
{form} class:active={selectedKey === `piece:${piece.id}`}
/> type="button"
{#if data.pieces.length} onclick={() => (selectedKey = `piece:${piece.id}`)}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
{#each data.pieces as piece (piece.id)}<article class="inbox-item">
<a href={resolve('/fundus/[id]', { id: piece.id })}
><span ><span
><strong>{piece.title}</strong><small ><small
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small >{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
></span ><strong>{piece.title}</strong></span
><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a ><AppIcon name="chevron-right" /></button
>{:else}<p class="empty-copy">Keine offenen Pieces.</p>{/each}
</section>
</div>
</main>
<aside class="content-quick-inspector" aria-label="Eingangsvorschau">
{#if selectedFeed}<header>
<div>
<p class="eyebrow">RSS-Vorschau</p>
<h2>{selectedFeed.title}</h2>
</div>
<a
class="inspector-expand-button"
href={selectedFeed.url}
target="_blank"
rel="noopener external"
aria-label="Originalbeitrag öffnen"
title="Original öffnen"><AppIcon name="arrow-up-right-from-square" /></a
> >
<details class="piece-actions"> </header>
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary <div class="content-quick-inspector-scroll">
<section>
<strong>{selectedFeed.feedName}</strong>{#if selectedFeed.publishedAtUtc}<small
>{new Date(selectedFeed.publishedAtUtc).toLocaleDateString('de-DE')}</small
>{/if}
</section>
{#if selectedFeed.summary}<section>
<h3>Vorschau</h3>
<p>{selectedFeed.summary}</p>
</section>{/if}
<section>
<h3>Übernahme</h3>
<p>
Bei der Übernahme entsteht ein normales Piece im redaktionellen Eingang. Ohne Auswahl
gelten die Standardkategorien des Feeds.
</p>
</section>
</div>
<footer>
<form method="POST" action="?/importFeeds">
<input type="hidden" name="entryIds" value={selectedFeed.id} /><button
class="text-button danger-text"
type="submit"
formaction="?/discardFeeds">Verwerfen</button
><button class="primary-button" type="submit">Übernehmen</button>
</form>
</footer>
{:else if selectedPiece}<header>
<div>
<p class="eyebrow">Schnellbearbeitung</p>
<h2>{selectedPiece.title}</h2>
</div>
<a
class="inspector-expand-button"
href={resolve('/fundus/[id]', { id: selectedPiece.id })}
aria-label="Piece vollständig bearbeiten"
title="Vollständig bearbeiten"><AppIcon name="expand" /></a
> >
<div class="piece-actions-popover"> </header>
<div class="content-quick-inspector-scroll">
<form id="inbox-quick-form" method="POST" action="?/quickSaveInbox">
<input type="hidden" name="pieceId" value={selectedPiece.id} /><input
type="hidden"
name="revision"
value={selectedPiece.revision}
/><label
>Titel<input name="title" required maxlength="200" value={selectedPiece.title} /></label
><label
>Kurzbeschreibung<textarea name="summary" rows="7" maxlength="2000"
>{selectedPiece.summary}</textarea
></label
>
</form>
<section>
<h3>Herkunft</h3>
<p>
{selectedPiece.sourceCount
? `${selectedPiece.sourceCount} Quelle`
: 'Schnell aufgenommener Gedanke ohne Link'}
</p>
</section>
</div>
<footer>
<form <form
method="POST" method="POST"
action="?/delete" action="?/delete"
onsubmit={(event) => onsubmit={(event) =>
!confirm(`„${piece.title}“ löschen?`) && event.preventDefault()} !confirm(`„${selectedPiece.title}“ löschen?`) && event.preventDefault()}
> >
<input type="hidden" name="pieceId" value={piece.id} /><input <input type="hidden" name="pieceId" value={selectedPiece.id} /><input
type="hidden" type="hidden"
name="revision" name="revision"
value={piece.revision} value={selectedPiece.revision}
/> /><button class="text-button danger-text" type="submit">Löschen</button>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form> </form>
</div> <button class="primary-button" type="submit" form="inbox-quick-form">Speichern</button>
</details> </footer>
</article>{/each} {:else}<div class="content-inspector-empty">
</section> <AppIcon name="inbox" />
{:else}<section class="quiet-panel planned-panel"> <h2>Eintrag auswählen</h2>
<div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div> <p>Vorschau und Übernahme erscheinen hier.</p>
<h2>Eingang ist leer</h2> </div>{/if}
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p> </aside>
</section>{/if}
</div> </div>
<style> <RssSelectionDialog
.rss-inbox-panel { bind:this={rssDialog}
display: grid; entries={data.feedEntries}
gap: 0.75rem; categories={data.taxonomy.categories}
margin-block: 1.5rem; {form}
padding: clamp(0.85rem, 2vw, 1.2rem); />
}
.rss-inbox-panel .panel-heading {
margin: 0;
}
.rss-inbox-panel .catalog-note {
margin: 0;
}
</style>
+3 -3
View File
@@ -15,7 +15,7 @@
} }
</script> </script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head> <svelte:head><title>Streamfreigabe | Sendewerk</title></svelte:head>
<main class="share-page"> <main class="share-page">
{#if data.status === 'unavailable'} {#if data.status === 'unavailable'}
@@ -38,13 +38,13 @@
autocomplete="current-password" autocomplete="current-password"
/></label /></label
> >
<button class="primary-button" type="submit">Sendung öffnen</button> <button class="primary-button" type="submit">Stream öffnen</button>
</form> </form>
</section> </section>
{:else} {:else}
<header class="share-heading"> <header class="share-heading">
<p class="eyebrow"> <p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen {data.share.isCompleted ? 'Abgeschlossener Stream' : 'Aktuelle Streamplanung'} · Nur lesen
</p> </p>
<h1>{data.share.title}</h1> <h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p> <p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
+73 -35
View File
@@ -5,47 +5,52 @@ import {
createPiece, createPiece,
deletePieceTemplate, deletePieceTemplate,
duplicatePiece, duplicatePiece,
getTaxonomy, getPiece,
listPieces,
listPieceTemplates, listPieceTemplates,
savePiece,
setPieceArchived, setPieceArchived,
setPieceDeleted, setPieceDeleted,
setPieceFavorite setPieceFavorite,
uploadPieceImage,
type EditablePieceStatus,
type PieceEditor
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js'; import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); requireUser(locals.user);
const filters = { const workspace = loadFundusWorkspace(url);
query: url.searchParams.get('q') ?? '', const selectedId = url.searchParams.get('selected') ?? workspace.pieces[0]?.id ?? '';
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
};
const pieces = listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
return { return {
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), ...workspace,
taxonomy: getTaxonomy(), selectedPiece: workspace.pieces.some((piece) => piece.id === selectedId)
templates: listPieceTemplates(), ? getPiece(selectedId)
filters : null,
templates: listPieceTemplates()
}; };
}; };
function preservedInput(piece: PieceEditor, data: FormData) {
return {
...piece,
title: String(data.get('title') || ''),
summary: String(data.get('summary') || ''),
status: String(data.get('status') || piece.status) as EditablePieceStatus,
formatId: String(data.get('formatId') || '') || null,
durationSeconds: Math.round(Number(data.get('durationMinutes')) * 60),
intro: String(data.get('intro') || ''),
moderationNotes: String(data.get('moderationNotes') || ''),
topicIds: piece.topicIds,
categoryIds: piece.categoryIds,
sources: piece.sources,
tools: piece.tools.map((tool) => ({
toolId: tool.toolId,
workUrl: tool.workUrl,
note: tool.note
}))
};
}
function actionError(cause: unknown) { function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : ''; const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.'; if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.';
@@ -54,7 +59,7 @@ function actionError(cause: unknown) {
} }
export const actions = { export const actions = {
create: async ({ locals, request }) => { create: async ({ locals, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string; let id: string;
@@ -63,7 +68,7 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) }); return fail(400, { intent: 'create', error: actionError(cause) });
} }
redirect(303, `/fundus/${id}`); redirect(303, `/fundus/${id}${fundusSearch(url)}`);
}, },
deleteTemplate: async ({ locals, request }) => { deleteTemplate: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -75,7 +80,7 @@ export const actions = {
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) }); return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
} }
}, },
duplicate: async ({ locals, request }) => { duplicate: async ({ locals, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string; let id: string;
@@ -84,7 +89,7 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) }); return fail(400, { intent: 'duplicate', error: actionError(cause) });
} }
redirect(303, `/fundus/${id}`); redirect(303, `/fundus/${id}${fundusSearch(url)}`);
}, },
archive: async ({ locals, request }) => { archive: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -113,10 +118,43 @@ export const actions = {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true); setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { return {
intent: 'delete', intent: 'delete',
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.' success: 'Piece gelöscht. Bestehende Streamfassungen bleiben erhalten.'
}; };
} catch (cause) { } catch (cause) {
return fail(409, { intent: 'delete', error: actionError(cause) }); return fail(409, { intent: 'delete', error: actionError(cause) });
} }
},
quickSave: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const id = String(data.get('pieceId') || '');
const piece = getPiece(id);
if (!piece || piece.status === 'archived')
return fail(404, { intent: 'quickSave', error: 'Das Piece wurde nicht gefunden.' });
try {
const saved = savePiece(id, Number(data.get('revision')), preservedInput(piece, data));
return { intent: 'quickSave', success: 'Schnelländerungen gespeichert.', ...saved };
} catch (cause) {
return fail(409, { intent: 'quickSave', error: actionError(cause) });
}
},
quickUpload: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const image = data.get('image');
if (!(image instanceof File))
return fail(400, { intent: 'quickUpload', error: 'Bitte ein Bild auswählen.' });
try {
await uploadPieceImage(
String(data.get('pieceId') || ''),
Number(data.get('revision')),
image,
String(data.get('altText') || ''),
false
);
return { intent: 'quickUpload', success: 'Bild hochgeladen.' };
} catch (cause) {
return fail(400, { intent: 'quickUpload', error: actionError(cause) });
}
} }
} satisfies Actions; } satisfies Actions;
+187 -33
View File
@@ -4,8 +4,10 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
import { formatClock } from '#lib/format.js'; import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -15,8 +17,8 @@
let visiblePieces = $derived( let visiblePieces = $derived(
data.pieces.filter( data.pieces.filter(
(piece) => (piece) =>
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) && (data.filters.view === 'completed' || showUsedPieces || piece.sentHistory.length === 0) &&
(showPlannedPieces || !piece.isPlannedInActiveShow) (data.filters.view === 'completed' || showPlannedPieces || !piece.isPlannedInActiveShow)
) )
); );
onMount(() => { onMount(() => {
@@ -26,9 +28,36 @@
showPlannedPieces = search.get('includePlanned') === '1'; showPlannedPieces = search.get('includePlanned') === '1';
}; };
window.addEventListener('popstate', syncFiltersFromHistory); window.addEventListener('popstate', syncFiltersFromHistory);
const scrollKey = `fundus-scroll:${window.location.search}`;
const storedScroll = Number(sessionStorage.getItem(scrollKey));
if (Number.isFinite(storedScroll))
requestAnimationFrame(() => window.scrollTo(0, storedScroll));
if (new URL(window.location.href).searchParams.get('create') === '1') {
document.querySelector<HTMLDetailsElement>('.template-picker')?.setAttribute('open', '');
}
return () => window.removeEventListener('popstate', syncFiltersFromHistory); return () => window.removeEventListener('popstate', syncFiltersFromHistory);
}); });
function rememberListPosition() {
sessionStorage.setItem(`fundus-scroll:${window.location.search}`, String(window.scrollY));
}
function pieceSearch(selectedId = '') {
const search = new SvelteURLSearchParams();
if (data.filters.query) search.set('q', data.filters.query);
if (data.filters.status) search.set('status', data.filters.status);
if (data.filters.formatId) search.set('format', data.filters.formatId);
if (data.filters.topicId) search.set('topic', data.filters.topicId);
if (data.filters.categoryId) search.set('category', data.filters.categoryId);
if (data.filters.favorites) search.set('favorites', '1');
if (data.filters.view) search.set('view', data.filters.view);
if (showUsedPieces) search.set('includeUsed', '1');
if (showPlannedPieces) search.set('includePlanned', '1');
if (selectedId) search.set('selected', selectedId);
const query = search.toString();
return query ? `?${query}` : '';
}
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) { function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked; if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked; else showPlannedPieces = checked;
@@ -53,13 +82,16 @@
}; };
</script> </script>
<svelte:head><title>Fundus | Sendewerk</title></svelte:head> <svelte:head><title>Pieces | Sendewerk</title></svelte:head>
<div class="page-wrap"> <div class="fundus-workspace fundus-list-workspace unified-workspace">
<FundusNavigation filters={data.filters} counts={data.counts} topics={data.taxonomy.topics} />
<section class="fundus-list-pane">
<header class="page-heading split-heading"> <header class="page-heading split-heading">
<div> <div>
<h1>Fundus</h1> <p class="eyebrow">Redaktionelle Bibliothek</p>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <h1>Pieces</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Streams.</p>
</div> </div>
<div class="fundus-heading-actions"> <div class="fundus-heading-actions">
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}> <div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
@@ -102,25 +134,14 @@
</details> </details>
</div> </div>
</header> </header>
<div class="fundus-library-content">
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<form <form id="library-filters" class="library-filters" method="GET" aria-label="Pieces filtern">
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<div class="fundus-search-row"> <div class="fundus-search-row">
<label class="search-field" <input type="hidden" name="q" value={data.filters.query} />
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input {#if data.filters.view}<input type="hidden" name="view" value={data.filters.view} />{/if}
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker"> <details class="filter-picker">
<summary><AppIcon name="list-check" /> Weitere Filter</summary> <summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel"> <div class="filter-panel">
@@ -136,7 +157,8 @@
> >
<label <label
><span>Format</span><select name="format" ><span>Format</span><select name="format"
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option ><option value="">Alle</option
>{#each data.taxonomy.formats as item (item.id)}<option
value={item.id} value={item.id}
selected={data.filters.formatId === item.id}>{item.name}</option selected={data.filters.formatId === item.id}>{item.name}</option
>{/each}</select >{/each}</select
@@ -144,7 +166,8 @@
> >
<label <label
><span>Thema</span><select name="topic" ><span>Thema</span><select name="topic"
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option ><option value="">Alle</option
>{#each data.taxonomy.topics as item (item.id)}<option
value={item.id} value={item.id}
selected={data.filters.topicId === item.id}>{item.name}</option selected={data.filters.topicId === item.id}>{item.name}</option
>{/each}</select >{/each}</select
@@ -159,14 +182,15 @@
>{/each}</select >{/each}</select
></label ></label
> >
<div class="library-filter-options" aria-label="Fundusauswahl erweitern"> <div class="library-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input <input
type="checkbox" type="checkbox"
name="includeUsed" name="includeUsed"
value="1" value="1"
checked={showUsedPieces} checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)} onchange={(event) =>
setSourceFilter('includeUsed', event.currentTarget.checked)}
/> />
<span>Auch verwendete Inhalte zeigen</span> <span>Auch verwendete Inhalte zeigen</span>
</label> </label>
@@ -176,14 +200,17 @@
name="includePlanned" name="includePlanned"
value="1" value="1"
checked={showPlannedPieces} checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)} onchange={(event) =>
setSourceFilter('includePlanned', event.currentTarget.checked)}
/> />
<span>Auch eingeplante Inhalte zeigen</span> <span>Auch eingeplante Inhalte zeigen</span>
</label> </label>
</div> </div>
<div class="filter-actions"> <div class="filter-actions">
<button class="secondary-button" type="submit">Filter anwenden</button> <button class="secondary-button" type="submit">Filter anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button> <button class="text-button" type="button" onclick={resetFilters}
>Zurücksetzen</button
>
</div> </div>
</div> </div>
</details> </details>
@@ -197,6 +224,12 @@
class="piece-card" class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`} style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
> >
<a
class="piece-row-link"
href={resolve(`/fundus${pieceSearch(piece.id)}` as '/fundus')}
onclick={rememberListPosition}
aria-label={`${piece.title} auswählen`}
></a>
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance> <form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
<input type="hidden" name="pieceId" value={piece.id} /> <input type="hidden" name="pieceId" value={piece.id} />
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} /> <input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
@@ -214,11 +247,14 @@
class="piece-thumbnail" class="piece-thumbnail"
src={`/media/${piece.mediaId}`} src={`/media/${piece.mediaId}`}
alt="" alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"> />{/if}
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
</span>{/if}
<div class="piece-primary"> <div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2> <h2>
<a
href={resolve(`/fundus${pieceSearch(piece.id)}` as '/fundus')}
onclick={rememberListPosition}>{piece.title}</a
>
</h2>
{#if piece.summary}<p>{piece.summary}</p>{/if} {#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
</div> </div>
@@ -244,7 +280,8 @@
> >
</div> </div>
<details class="piece-actions"> <details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary <summary aria-label={`Aktionen für ${piece.title}`}
><AppIcon name="ellipsis" /></summary
> >
<div class="piece-actions-popover"> <div class="piece-actions-popover">
<form method="POST" action="?/duplicate"> <form method="POST" action="?/duplicate">
@@ -265,7 +302,7 @@
action="?/delete" action="?/delete"
onsubmit={(event) => onsubmit={(event) =>
!confirm( !confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.` `Piece „${piece.title}“ löschen? Bestehende Streamfassungen bleiben erhalten.`
) && event.preventDefault()} ) && event.preventDefault()}
> >
<input type="hidden" name="pieceId" value={piece.id} /><input <input type="hidden" name="pieceId" value={piece.id} /><input
@@ -288,4 +325,121 @@
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a> <a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div> </div>
{/if} {/if}
</div>
</section>
<aside
class="content-quick-inspector stream-inspector builder-pane"
aria-label="Piece-Schnellbearbeitung"
>
{#if data.selectedPiece}
<header class="stream-inspector-header">
<div>
<p class="eyebrow">Schnellbearbeitung</p>
<a
class="inspector-expand-button"
href={resolve(
`/fundus/${encodeURIComponent(data.selectedPiece.id)}${pieceSearch()}` as '/fundus'
)}
aria-label="Vollständigen Piece-Editor öffnen"
title="Vollständig bearbeiten"><AppIcon name="expand" /></a
>
</div>
<h2>{data.selectedPiece.title}</h2>
<p>Wichtige Texte und Metadaten dieses Pieces.</p>
</header>
<div class="content-quick-inspector-scroll piece-quick-inspector-scroll">
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<form
id="piece-quick-form"
class="stream-inspector-form"
method="POST"
action="?/quickSave"
>
<input type="hidden" name="pieceId" value={data.selectedPiece.id} />
<input type="hidden" name="revision" value={data.selectedPiece.revision} />
<label
>Interner Titel<input
name="title"
required
maxlength="200"
value={data.selectedPiece.title}
/></label
>
<label
>Kurzbeschreibung<textarea name="summary" rows="4" maxlength="2000"
>{data.selectedPiece.summary}</textarea
></label
>
<label
>Einleitung<textarea name="intro" rows="6" maxlength="20000"
>{data.selectedPiece.intro}</textarea
></label
>
<label
>Moderationsstichpunkte<textarea name="moderationNotes" rows="7" maxlength="20000"
>{data.selectedPiece.moderationNotes}</textarea
></label
>
<div class="content-quick-grid">
<label
>Status<select name="status" value={data.selectedPiece.status}
><option value="preparing">In Vorbereitung</option><option value="ready"
>Sendefertig</option
><option value="inbox">Eingang</option></select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={Number((data.selectedPiece.durationSeconds / 60).toFixed(2))}
/></label
>
</div>
<label
>Format<select name="formatId"
><option value="">Ohne Format</option
>{#each data.taxonomy.formats as format (format.id)}<option
value={format.id}
selected={data.selectedPiece.formatId === format.id}>{format.name}</option
>{/each}</select
></label
>
</form>
<section class="quick-upload-section">
<h3>Bild hochladen</h3>
<form method="POST" action="?/quickUpload" enctype="multipart/form-data">
<input type="hidden" name="pieceId" value={data.selectedPiece.id} />
<input type="hidden" name="revision" value={data.selectedPiece.revision} />
<label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
>
<label>Alternativtext<input name="altText" maxlength="500" /></label>
<button class="secondary-button" type="submit">Hochladen</button>
</form>
</section>
</div>
<footer class="stream-inspector-actions">
<a class="secondary-button" href={resolve('/fundus/[id]', { id: data.selectedPiece.id })}
>Vollständig bearbeiten</a
>
<button class="primary-button" type="submit" form="piece-quick-form">Speichern</button>
</footer>
{:else}
<div class="content-inspector-empty">
<AppIcon name="arrow-pointer" />
<h2>Piece auswählen</h2>
<p>Wichtige Texte, Einordnung und Uploads erscheinen hier.</p>
</div>
{/if}
</aside>
</div> </div>
+11 -9
View File
@@ -19,6 +19,7 @@ import {
type PieceSourceInput type PieceSourceInput
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js'; import { renderMarkdown } from '#lib/server/markdown.js';
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
import { listTools } from '#lib/server/tools.js'; import { listTools } from '#lib/server/tools.js';
function renderedFields( function renderedFields(
@@ -138,12 +139,13 @@ function saveError(cause: unknown) {
}; };
} }
export const load: PageServerLoad = ({ locals, params }) => { export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user); requireUser(locals.user);
const piece = getPiece(params.id); const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden'); if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv'); if (piece.status === 'archived') redirect(303, '/archiv');
return { return {
workspace: loadFundusWorkspace(url),
piece, piece,
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
tools: listTools(true), tools: listTools(true),
@@ -165,7 +167,7 @@ export const actions = {
return fail(problem.status, { intent: 'save', ...problem }); return fail(problem.status, { intent: 'save', ...problem });
} }
}, },
loadPreview: async ({ locals, params, request }) => { loadPreview: async ({ locals, params, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
try { try {
@@ -188,7 +190,7 @@ export const actions = {
const problem = saveError(cause); const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem }); return fail(problem.status, { intent: 'loadPreview', ...problem });
} }
redirect(303, `/fundus/${params.id}`); redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
}, },
saveTemplate: async ({ locals, params, request }) => { saveTemplate: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -209,7 +211,7 @@ export const actions = {
return fail(problem.status, { intent: 'saveTemplate', ...problem }); return fail(problem.status, { intent: 'saveTemplate', ...problem });
} }
}, },
upload: async ({ locals, params, request }) => { upload: async ({ locals, params, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
const image = data.get('image'); const image = data.get('image');
@@ -239,9 +241,9 @@ export const actions = {
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' }); return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' }); return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
} }
redirect(303, `/fundus/${params.id}`); redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
}, },
deleteImage: async ({ locals, params, request }) => { deleteImage: async ({ locals, params, request, url }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
try { try {
@@ -249,9 +251,9 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error }); return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
} }
redirect(303, `/fundus/${params.id}`); redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
}, },
duplicate: async ({ locals, params }) => { duplicate: async ({ locals, params, url }) => {
requireUser(locals.user); requireUser(locals.user);
let id: string; let id: string;
try { try {
@@ -259,7 +261,7 @@ export const actions = {
} catch { } catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' }); return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
} }
redirect(303, `/fundus/${id}`); redirect(303, `/fundus/${id}${fundusSearch(url)}`);
}, },
archive: async ({ locals, params, request }) => { archive: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
+213 -131
View File
@@ -2,8 +2,9 @@
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte'; import RichMarkdownField from '#lib/components/RichMarkdownField.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ChoicePicker from '#lib/components/ChoicePicker.svelte'; import ChoicePicker from '#lib/components/ChoicePicker.svelte';
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte'; import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
@@ -94,11 +95,16 @@
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
const statusLabels = { $effect(() => {
inbox: 'Eingang', const incoming = data.piece;
preparing: 'In Vorbereitung', if (incoming.revision === revision || !['idle', 'saved'].includes(saveState) || saving) return;
ready: 'Sendefertig' 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()); onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer)); onDestroy(() => clearTimeout(timer));
@@ -117,7 +123,12 @@
function scheduleSave(event?: Event) { function scheduleSave(event?: Event) {
const control = event?.target; 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 ( if (
control instanceof HTMLInputElement && control instanceof HTMLInputElement &&
control.name === 'presentationMode' && control.name === 'presentationMode' &&
@@ -133,16 +144,27 @@
timer = setTimeout(submitSave, 900); timer = setTimeout(submitSave, 900);
} }
function schedulePresentationModeSave(mode: string) {
if (
(['link', 'video'].includes(mode) && draft.presentationSourcePosition === null) ||
(mode === 'image' && !draft.presentationMediaId)
)
return;
scheduleSave();
}
function submitSave() { function submitSave() {
if (saving) return; if (saving) return;
editorForm.requestSubmit(saveButton); editorForm.requestSubmit(saveButton);
} }
function requestClose() { function requestClose() {
const target = resolve('/fundus'); const target = `${resolve('/fundus')}${page.url.search}`;
if (saveState === 'error' || saveState === 'conflict') { if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return; if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
allowNavigation = true; allowNavigation = true;
// The target combines the resolved Fundus route with the current filter query.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(target); void goto(target);
return; return;
} }
@@ -152,6 +174,8 @@
return; return;
} }
allowNavigation = true; allowNavigation = true;
// The target combines the resolved Fundus route with the current filter query.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(target); void goto(target);
} }
@@ -246,9 +270,10 @@
}; };
</script> </script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head> <svelte:head><title>{draft.title} | Pieces | Sendewerk</title></svelte:head>
<div class="piece-editor-route"> <div class="editor-route-shell">
<div class="piece-editor-route">
{#if form?.error}<div class="form-message error route-editor-error" role="alert"> {#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error} {form.error}
</div>{/if} </div>{/if}
@@ -259,25 +284,118 @@
onrequestclose={requestClose} onrequestclose={requestClose}
> >
{#snippet header()} {#snippet header()}
<div class="piece-editor-kicker"> <div class="piece-editor-title-row">
<p class="eyebrow">Fundusfassung</p> <InlineTitleEditor
form="piece-editor-form"
bind:value={draft.title}
oncommit={scheduleSave}
/>
<form method="POST" action="?/favorite" use:enhance> <form method="POST" action="?/favorite" use:enhance>
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} /> <input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
<button <button
class="piece-favorite" class="piece-favorite"
class:active={data.piece.isFavorite} class:active={data.piece.isFavorite}
type="submit" type="submit"
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'} aria-label={data.piece.isFavorite
? 'Aus Favoriten entfernen'
: 'Als Favorit markieren'}
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
> >
</form> </form>
</div> </div>
<InlineTitleEditor
form="piece-editor-form"
bind:value={draft.title}
oncommit={scheduleSave}
/>
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1> <h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
<div class="piece-editor-header-actions">
<span
class="save-indicator"
class:problem={saveState === 'error' || saveState === 'conflict'}
role="status"
aria-live="polite">{saveMessage}</span
>
<button
bind:this={saveButton}
class="compact-save-button"
type="submit"
form="piece-editor-form"
disabled={saving}
>{saveState === 'error' || saveState === 'conflict'
? 'Erneut speichern'
: 'Speichern'}</button
>
<details class="piece-action-menu">
<summary>Aktionen</summary>
<div>
<details class="template-save-menu">
<summary>Als Vorlage speichern</summary>
<form method="POST" action="?/saveTemplate">
<label
>Vorlage<select name="templateRef">
<option value="">Neue Vorlage</option>
{#each data.templates as template (template.id)}<option
value={`${template.id}|${template.revision}`}>{template.name}</option
>{/each}</select
></label
>
<label>Name<input name="templateName" required maxlength="120" /></label>
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
<button class="text-button" type="submit" disabled={actionsBlocked}
>Vorlage speichern</button
>
</form>
</details>
<form method="POST" action="?/duplicate">
<button class="text-button" type="submit" disabled={actionsBlocked}
>Duplizieren</button
>
</form>
<form
method="POST"
action="?/archive"
onsubmit={(event) => !confirm('Piece archivieren?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
aria-label="Piece archivieren"
disabled={actionsBlocked}>Archivieren</button
>
</form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Streamfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece löschen</button
>
</form>
</div>
</details>
</div>
<nav class="piece-editor-view-switch" aria-label="Arbeitsbereich">
<button
type="button"
class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => (activeSection = 'script')}>Streamtext</button
>
<button
type="button"
class:active={activeSection === 'research'}
aria-current={activeSection === 'research' ? 'page' : undefined}
onclick={() => (activeSection = 'research')}>Recherche & Quellen</button
>
<button
class="piece-context-switch"
type="button"
class:active={activeSection === 'presentation'}
aria-current={activeSection === 'presentation' ? 'page' : undefined}
onclick={() => (activeSection = 'presentation')}>Details & Ausgabe</button
>
</nav>
{/snippet} {/snippet}
<form <form
@@ -292,48 +410,52 @@
> >
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
<section class="piece-editor-panel" hidden={activeSection !== 'script'}> <section class="piece-editor-panel piece-script-panel">
<div class="piece-panel-heading"> <div class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Für die Moderation</p> <p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2> <h2>Streamtext</h2>
</div> </div>
</div> </div>
<div class="piece-writing-stack"> <div
<MarkdownEditorField class="piece-writing-stack piece-writing-document"
hidden={activeSection !== 'script'}
>
<RichMarkdownField
name="intro" name="intro"
label="Einleitung" label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?" placeholder="Wie beginnt dieses Thema im Stream?"
bind:value={draft.intro} bind:value={draft.intro}
/> />
<MarkdownEditorField <RichMarkdownField
name="moderationNotes" name="moderationNotes"
label="Moderationsstichpunkte" label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte" placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10} rows={10}
bind:value={draft.moderationNotes} bind:value={draft.moderationNotes}
/> />
<MarkdownEditorField <RichMarkdownField
name="interactionText" name="interactionText"
label="Interaktion" label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?" placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField privateField
bind:value={draft.interactionText} bind:value={draft.interactionText}
/> />
<MarkdownEditorField <RichMarkdownField
name="transitionText" name="transitionText"
label="Überleitung" label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?" placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText} bind:value={draft.transitionText}
/> />
</div> </div>
<details class="piece-meta-details"> <section
<summary> class="piece-meta-details piece-context-panel"
<span>Status, Dauer und Einordnung</span> class:mobile-context-hidden={activeSection !== 'presentation'}
<small
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
> >
</summary> <div class="piece-context-heading">
<h2>Details</h2>
<small>Intern</small>
</div>
<div class="piece-meta-grid"> <div class="piece-meta-grid">
<label <label
>Status<select name="status" bind:value={draft.status} >Status<select name="status" bind:value={draft.status}
@@ -352,14 +474,6 @@
bind:value={durationMinutes} bind:value={durationMinutes}
/></label /></label
> >
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option></select
></label
>
<label <label
>Format<select name="formatId" bind:value={draft.formatId} >Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option ><option value={null}>Ohne Format</option
@@ -368,14 +482,6 @@
>{/each}</select >{/each}</select
></label ></label
> >
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
placeholder="Worum geht es in wenigen Sätzen?"
bind:value={draft.summary}></textarea></label
>
<fieldset> <fieldset>
<legend>Themen</legend><ChoicePicker <legend>Themen</legend><ChoicePicker
items={data.taxonomy.topics} items={data.taxonomy.topics}
@@ -388,6 +494,27 @@
}} }}
/> />
</fieldset> </fieldset>
</div>
<details class="piece-more-details">
<summary>Weitere Details</summary>
<div>
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option
></select
></label
>
<label
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
placeholder="Worum geht es in wenigen Sätzen?"
bind:value={draft.summary}></textarea></label
>
<fieldset> <fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend> <legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid"> <div class="choice-grid">
@@ -404,8 +531,12 @@
</div> </div>
</details> </details>
</section> </section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}> <section
class="piece-editor-panel piece-research-panel"
hidden={activeSection !== 'research'}
>
<div class="piece-panel-heading"> <div class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Nicht öffentlich</p> <p class="eyebrow">Nicht öffentlich</p>
@@ -413,7 +544,7 @@
</div> </div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small> <small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div> </div>
<MarkdownEditorField <RichMarkdownField
name="researchText" name="researchText"
label="Private Recherche" label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen" placeholder="Hintergrund, Prüfung und offene Fragen"
@@ -524,7 +655,10 @@
</section> </section>
</section> </section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}> <section
class="piece-editor-panel piece-presentation-panel piece-context-panel"
class:mobile-context-hidden={activeSection !== 'presentation'}
>
<div class="piece-panel-heading"> <div class="piece-panel-heading">
<div> <div>
<p class="eyebrow">Öffentliche Ausgabe</p> <p class="eyebrow">Öffentliche Ausgabe</p>
@@ -547,25 +681,29 @@
name="presentationMode" name="presentationMode"
value={mode[0]} value={mode[0]}
bind:group={draft.presentationMode} bind:group={draft.presentationMode}
onchange={() => schedulePresentationModeSave(mode[0])}
/>{mode[1]}</label />{mode[1]}</label
>{/each} >{/each}
</div> </div>
{#if draft.presentationMode === 'text'} {#if draft.presentationMode === 'text'}
<div class="piece-inline-presentation-preview">
<p class="output-kicker">Publikumsvorschau · direkt bearbeiten</p>
<label <label
>Öffentliche Überschrift<input >Öffentliche Überschrift<input
name="outputTitle" name="outputTitle"
maxlength="300" maxlength="300"
placeholder="Optional, sonst interner Titel" placeholder={draft.title}
bind:value={draft.outputTitle} bind:value={draft.outputTitle}
/></label /></label
> >
<MarkdownEditorField <RichMarkdownField
name="presentationText" name="presentationText"
label="Öffentlicher Vorspann" label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe" placeholder="Text für die Publikumsausgabe"
rows={8} rows={8}
bind:value={draft.presentationText} bind:value={draft.presentationText}
/> />
</div>
{:else} {:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} /> <input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentationText" value={draft.presentationText} /> <input type="hidden" name="presentationText" value={draft.presentationText} />
@@ -617,7 +755,8 @@
maxlength="1000" maxlength="1000"
placeholder="Kurzer Text zur Vorschau" placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription} bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label oninput={() => (draft.presentationDescriptionManual = true)}
></textarea></label
> >
<div class="presentation-preview-actions wide-field"> <div class="presentation-preview-actions wide-field">
<button <button
@@ -645,7 +784,11 @@
name="presentationDescription" name="presentationDescription"
value={draft.presentationDescription} value={draft.presentationDescription}
/> />
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if} {/if}
</div> </div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
@@ -683,9 +826,9 @@
</form> </form>
<section <section
class="piece-editor-panel piece-media-panel" class="piece-editor-panel piece-media-panel piece-context-panel"
hidden={activeSection !== 'presentation' || class:mobile-context-hidden={activeSection !== 'presentation'}
!['text', 'image'].includes(draft.presentationMode)} hidden={!['text', 'image'].includes(draft.presentationMode)}
> >
<div class="piece-subsection-heading"> <div class="piece-subsection-heading">
<div> <div>
@@ -727,14 +870,22 @@
type="hidden" type="hidden"
name="mediaId" name="mediaId"
value={media.id} value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked} /><button
>Entfernen</button class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Entfernen</button
> >
</form> </form>
</figcaption> </figcaption>
</figure>{/each} </figure>{/each}
</div>{/if} </div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form"> <form
method="POST"
action="?/upload"
enctype="multipart/form-data"
class="upload-form"
use:enhance
>
<input type="hidden" name="revision" value={revision} /><label <input type="hidden" name="revision" value={revision} /><label
>Bild<input >Bild<input
name="image" name="image"
@@ -757,76 +908,6 @@
> >
</form> </form>
</section> </section>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<details class="template-save-menu">
<summary>Als Vorlage speichern</summary>
<form method="POST" action="?/saveTemplate">
<label
>Vorlage<select name="templateRef">
<option value="">Neue Vorlage</option>
{#each data.templates as template (template.id)}<option
value={`${template.id}|${template.revision}`}>{template.name}</option
>{/each}
</select></label
>
<label>Name<input name="templateName" required maxlength="120" /></label>
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
<button class="text-button" type="submit" disabled={actionsBlocked}
>Vorlage speichern</button
>
</form>
</details>
<form method="POST" action="?/duplicate">
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
aria-label="Piece archivieren"
disabled={actionsBlocked}>Archivieren</button
>
</form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece löschen</button
>
</form>
</div>
<div class="piece-dialog-primary-action">
<span
class="save-indicator"
class:problem={saveState === 'error' || saveState === 'conflict'}
role="status"
aria-live="polite">{saveMessage}</span
>
<button
bind:this={saveButton}
class:primary-button={saveState === 'dirty' ||
saveState === 'error' ||
saveState === 'conflict'}
class:secondary-button={saveState === 'idle' ||
saveState === 'saved' ||
saveState === 'saving'}
type="submit"
form="piece-editor-form"
disabled={saving}
>{saveState === 'error' || saveState === 'conflict'
? 'Erneut speichern'
: 'Speichern'}</button
>
</div>
{/snippet}
</PieceEditorDialog> </PieceEditorDialog>
<form <form
@@ -838,4 +919,5 @@
> >
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
</form> </form>
</div>
</div> </div>
+21 -329
View File
@@ -1,353 +1,45 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireBarkeeper, requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js'; import { getLiveView, presentUsage } from '#lib/server/live.js';
import {
completeCurrent,
clearPresentationChannel,
endLive,
getLiveView,
getPresentationChannelStatus,
goBack,
issuePresentationAccess,
moveOpenUsage,
pauseLive,
presentUsage,
rotatePresentationChannelKey,
resetSectionTimer,
resetTotalTimer,
resumeUsage,
resumeEndedLive,
resumeLive,
selectUsage,
setShowStatus,
skipCurrent,
startNewLiveRun,
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js'; import { listShows } from '#lib/server/shows.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NOT_STARTED')
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
status: 409,
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
if (code === 'NO_SECTION_TIMER')
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
}
function attempt(intent: string, operation: () => 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 }) => { export const load: PageServerLoad = ({ locals, url }) => {
const actor = requireUser(locals.user); requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id; const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden'); if (!showId) error(404, 'Kein Stream vorhanden');
try { try {
const live = getLiveView(showId); return { live: getLiveView(showId), commandId: randomUUID() };
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
};
} catch (cause) { } catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND') if (cause instanceof Error && cause.message === 'NOT_FOUND') {
error(404, 'Sendung nicht gefunden'); error(404, 'Stream nicht gefunden');
}
throw cause; throw cause;
} }
}; };
export const actions = { 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 Sendungsstatus 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 }) => { present: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
return attempt('present', () => try {
presentUsage( presentUsage(
text(data, 'showId'), String(data.get('showId') || ''),
text(data, 'usageId') || null, String(data.get('usageId') || '') || null,
text(data, 'commandId'), String(data.get('commandId') || ''),
Number(data.get('revision')) Number(data.get('revision'))
)
); );
}, return { intent: 'present', success: true };
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}` };
} catch (cause) { } catch (cause) {
const problem = actionError(cause); const code = cause instanceof Error ? cause.message : '';
return fail(problem.status, { intent: 'createOutput', ...problem }); 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; } satisfies Actions;
+325 -1186
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,7 +10,7 @@
<h1>{data.clientName} verbinden?</h1> <h1>{data.clientName} verbinden?</h1>
<p> <p>
Die Anwendung erhält im Namen von <strong>{data.user.displayName}</strong> lesenden Zugriff auf redaktionelle Die Anwendung erhält im Namen von <strong>{data.user.displayName}</strong> lesenden Zugriff auf redaktionelle
Pieces, Sendungen und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und Pieces, Streams und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und
die Reservebank. die Reservebank.
</p> </p>
<div class="oauth-permission"> <div class="oauth-permission">
+35
View File
@@ -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 };
};
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import PostNavigation from '#lib/components/PostNavigation.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let selectedId = $state(untrack(() => data.shows[0]?.showId ?? ''));
let selected = $derived(
data.shows.find((show) => show.showId === selectedId) ?? data.shows[0] ?? null
);
function date(value: string | null) {
if (!value) return 'Datum nicht verfügbar';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'long'
}).format(new Date(value));
}
</script>
<svelte:head><title>Postproduktion | Sendewerk</title></svelte:head>
<div class="unified-workspace post-library-workspace">
<PostNavigation
query={data.query}
progress={data.progress}
sort={data.sort}
counts={data.counts}
/>
<section class="post-show-library workspace-content-pane" aria-labelledby="post-shows-title">
<header class="page-heading shows-heading post-page-heading">
<div>
<h1>Postproduktion</h1>
<p class="page-intro">YouTube, Social-Clips und Twitch-Highlights vorbereiten.</p>
</div>
</header>
<div class="post-library-content">
<div class="post-library-heading">
<div>
<p class="eyebrow">Abgeschlossene Streams</p>
<h2 id="post-shows-title">Streams</h2>
</div>
<span>{data.shows.length}</span>
</div>
{#if data.shows.length}
<div class="post-show-list">
{#each data.shows as show (show.showId)}
<a class="post-show-row" href={resolve('/post/[id]', { id: show.showId })}>
<div class="post-show-primary">
<span class="post-progress">{show.progress}</span>
<h3>{show.title}</h3>
<p>{date(show.startsAtUtc ?? show.completedAtUtc)}</p>
</div>
<dl class="post-show-counts">
<div>
<dt>Teile</dt>
<dd>{show.recordingPartCount}</dd>
</div>
<div>
<dt>Kontext</dt>
<dd>{show.sourceCount}</dd>
</div>
<div>
<dt>YouTube</dt>
<dd>{show.youtubeCount}</dd>
</div>
<div>
<dt>Clips</dt>
<dd>{show.clipCount}</dd>
</div>
<div>
<dt>Highlights</dt>
<dd>{show.highlightCount}</dd>
</div>
</dl>
<AppIcon name="chevron-right" />
</a>
{/each}
</div>
{:else}
<div class="empty-state compact">
<h2>Keine abgeschlossene Sendung gefunden</h2>
<p>Postproduktion beginnt mit einer in Live bewusst abgeschlossenen Sendung.</p>
</div>
{/if}
</div>
</section>
<aside
class="content-quick-inspector stream-inspector builder-pane"
aria-label="Post-Schnellübersicht"
>
{#if selected}<header class="stream-inspector-header">
<div>
<p class="eyebrow">Schnellübersicht</p>
<a
class="inspector-expand-button"
href={resolve('/post/[id]', { id: selected.showId })}
aria-label="Postproduktion vollständig öffnen"
title="Vollständig bearbeiten"><AppIcon name="expand" /></a
>
</div>
<h2>{selected.title}</h2>
<p>Material, Texte, Schnitt und Veröffentlichung.</p>
</header>
<div class="content-quick-inspector-scroll stream-inspector-form post-overview-inspector">
<section>
<span class="post-progress">{selected.progress}</span>
<p>{date(selected.startsAtUtc ?? selected.completedAtUtc)}</p>
</section>
<dl class="quick-fact-list">
<div>
<dt>Aufzeichnungsteile</dt>
<dd>{selected.recordingPartCount}</dd>
</div>
<div>
<dt>Kontextdateien</dt>
<dd>{selected.sourceCount}</dd>
</div>
<div>
<dt>YouTube-Videos</dt>
<dd>{selected.youtubeCount}</dd>
</div>
<div>
<dt>Social-Clips</dt>
<dd>{selected.clipCount}</dd>
</div>
<div>
<dt>Twitch-Highlights</dt>
<dd>{selected.highlightCount}</dd>
</div>
</dl>
<section class="workspace-guidance">
<h3>Vollständige Bearbeitung</h3>
<p>
Material, Texte, Uploads, Schnittdaten und Exporte öffnen in der großen
Bearbeitungsfläche.
</p>
</section>
</div>
<footer class="stream-inspector-actions single-action">
<a class="primary-button" href={resolve('/post/[id]', { id: selected.showId })}
>Postproduktion öffnen</a
>
</footer>
{:else}<div class="content-inspector-empty">
<AppIcon name="clapperboard" />
<h2>Stream auswählen</h2>
<p>Bearbeitungsstand und Inhalte erscheinen hier.</p>
</div>{/if}
</aside>
</div>
+269
View File
@@ -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;
File diff suppressed because it is too large. Load diff
@@ -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');
}
};
@@ -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"
}
});
};
@@ -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'
}
});
};
+27 -2
View File
@@ -15,8 +15,33 @@ export const load: PageServerLoad = ({ locals, url }) => {
const today = new Date(); const today = new Date();
const todayMonth = berlinMonthKey(today); const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth); const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
const query = (url.searchParams.get('q') ?? '').trim();
const state = url.searchParams.get('state') ?? '';
const allShows = listShows();
const searchedShows = query
? allShows.filter((show) =>
show.title.toLocaleLowerCase('de-DE').includes(query.toLocaleLowerCase('de-DE'))
)
: allShows;
const shows = searchedShows.filter((show) => {
if (state === 'created') return show.status === 'draft' && show.sectionCount === 0;
if (state === 'preparing') return show.status === 'draft' && show.sectionCount > 0;
if (state === 'ready') return show.status === 'ready' || show.status === 'live';
if (state === 'completed') return show.status === 'completed';
return true;
});
return { return {
shows: listShows(['draft', 'ready', 'live']), shows,
query,
state,
counts: {
all: allShows.length,
created: allShows.filter((show) => show.status === 'draft' && show.sectionCount === 0).length,
preparing: allShows.filter((show) => show.status === 'draft' && show.sectionCount > 0).length,
ready: allShows.filter((show) => show.status === 'ready' || show.status === 'live').length,
completed: allShows.filter((show) => show.status === 'completed').length
},
plannedShows: allShows.filter((show) => show.status !== 'completed').slice(0, 6),
preparationUsages: listPreparationUsages(), preparationUsages: listPreparationUsages(),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
@@ -65,7 +90,7 @@ export const actions = {
}); });
} }
return fail(400, { return fail(400, {
error: 'Die Sendung konnte nicht angelegt werden.', error: 'Der Stream konnte nicht angelegt werden.',
title, title,
startsAt, startsAt,
templateId templateId
+43 -20
View File
@@ -3,6 +3,8 @@
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js'; import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js'; import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import StreamNavigation from '#lib/components/StreamNavigation.svelte';
import { onMount } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -30,6 +32,11 @@
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}); });
onMount(() => {
if (new URL(window.location.href).searchParams.get('create') === '1') {
document.querySelector<HTMLDetailsElement>('.show-create-panel')?.setAttribute('open', '');
}
});
function berlinDate(value: string | null) { function berlinDate(value: string | null) {
if (!value) return 'Termin offen'; if (!value) return 'Termin offen';
@@ -57,21 +64,28 @@
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`; return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
} }
function statusLabel(status: string) { function statusLabel(status: string, sectionCount = 1) {
if (status === 'ready') return 'Bereit'; if (status === 'ready') return 'Sendefertig';
if (status === 'draft') return 'Entwurf'; if (status === 'draft') return sectionCount ? 'In Vorbereitung' : 'Angelegt';
if (status === 'live') return 'Live'; if (status === 'live') return 'Live';
return 'Abgeschlossen'; return 'Abgeschlossen';
} }
</script> </script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>Streams | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page"> <div class="stream-workspace">
<StreamNavigation
query={data.query}
state={data.state}
counts={data.counts}
shows={data.plannedShows}
/>
<div class="page-wrap shows-page">
<header class="page-heading shows-heading"> <header class="page-heading shows-heading">
<div> <div>
<h1>Sendungen</h1> <h1>Streams</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p> <p class="page-intro">Streams aus Pieces und freien Abschnitten zusammenstellen.</p>
</div> </div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung"> <form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} /> <input type="hidden" name="month" value={data.selectedMonth} />
@@ -87,7 +101,7 @@
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<details class="show-create-panel"> <details class="show-create-panel">
<summary><AppIcon name="plus" /> Neue Sendung planen</summary> <summary><AppIcon name="plus" /> Neuen Stream planen</summary>
<div class="show-create-heading"> <div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p> <p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2> <h2>Leer starten oder Ablauf übernehmen</h2>
@@ -99,7 +113,7 @@
required required
maxlength="200" maxlength="200"
value={form?.title ?? ''} value={form?.title ?? ''}
placeholder="Nächste heinzlive-Sendung" placeholder="Nächster heinzlive-Stream"
/></label /></label
> >
<label <label
@@ -111,7 +125,7 @@
><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon ><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
name="newspaper" name="newspaper"
/><span /><span
><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span ><strong>Leer starten</strong><small>Einen leeren Stream frei aufbauen.</small></span
></label ></label
> >
{#each data.templates as template (template.id)}<label {#each data.templates as template (template.id)}<label
@@ -125,7 +139,7 @@
></label ></label
>{/each} >{/each}
</fieldset> </fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button> <button class="primary-button" type="submit">Stream anlegen</button>
</form> </form>
</details> </details>
@@ -151,7 +165,8 @@
/>{/each}</span />{/each}</span
> >
<span <span
><strong>{usage.title}</strong><small>{usage.showTitle} · {usage.sectionName}</small ><strong>{usage.title}</strong><small
>{usage.showTitle} · {usage.sectionName}</small
></span ></span
> >
</div> </div>
@@ -169,7 +184,7 @@
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <header>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2> <h2 id="show-library-title">{data.view === 'list' ? 'Streams' : 'Kalender'}</h2>
<span>{data.shows.length}</span> <span>{data.shows.length}</span>
</header> </header>
@@ -180,7 +195,9 @@
href={resolve('/sendungen/[id]', { id: show.id })} href={resolve('/sendungen/[id]', { id: show.id })}
> >
<div> <div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span> <span class={`show-state ${show.status}`}
>{statusLabel(show.status, show.sectionCount)}</span
>
<h3>{show.title}</h3> <h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p> <p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div> </div>
@@ -192,8 +209,8 @@
</div> </div>
</a>{/each} </a>{/each}
</div>{:else}<div class="empty-state compact"> </div>{:else}<div class="empty-state compact">
<h2>Keine terminierten Sendungen</h2> <h2>Keine terminierten Streams</h2>
<p>Sendungen ohne Termin bleiben unten erreichbar.</p> <p>Streams ohne Termin bleiben unten erreichbar.</p>
</div>{/if} </div>{/if}
{:else} {:else}
<div class="show-month-toolbar"> <div class="show-month-toolbar">
@@ -208,7 +225,8 @@
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`} href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
> >
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a <a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}
>Heute</a
> >
<a <a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`} href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
@@ -239,7 +257,7 @@
> >
<time>{berlinTime(show.startsAtUtc!)}</time> <time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span> <span>{show.title}</span>
<small>{statusLabel(show.status)}</small> <small>{statusLabel(show.status, show.sectionCount)}</small>
</a>{/each} </a>{/each}
</div>{/if} </div>{/if}
</section>{/each} </section>{/each}
@@ -259,7 +277,9 @@
> >
<time>{berlinTime(show.startsAtUtc!)}</time> <time>{berlinTime(show.startsAtUtc!)}</time>
<span <span
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span ><strong>{show.title}</strong><small
>{statusLabel(show.status, show.sectionCount)}</small
></span
> >
</a>{/each} </a>{/each}
</div> </div>
@@ -284,7 +304,9 @@
href={resolve('/sendungen/[id]', { id: show.id })} href={resolve('/sendungen/[id]', { id: show.id })}
> >
<div> <div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span> <span class={`show-state ${show.status}`}
>{statusLabel(show.status, show.sectionCount)}</span
>
<h3>{show.title}</h3> <h3>{show.title}</h3>
<p>Termin offen</p> <p>Termin offen</p>
</div> </div>
@@ -294,4 +316,5 @@
</a>{/each} </a>{/each}
</div> </div>
</section>{/if} </section>{/if}
</div>
</div> </div>
+42 -7
View File
@@ -26,7 +26,9 @@ import {
removePieceUsage, removePieceUsage,
removeShowSection, removeShowSection,
saveShowMetadata, saveShowMetadata,
listShows,
updatePieceUsage, updatePieceUsage,
updatePieceUsageMetadata,
updateShowSection, updateShowSection,
type EditableShowStatus, type EditableShowStatus,
type PieceUsageUpdate type PieceUsageUpdate
@@ -66,15 +68,15 @@ function actionError(cause: unknown) {
if (code === 'CONFLICT') if (code === 'CONFLICT')
return { return {
status: 409, status: 409,
error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.', error: 'Der Stream wurde inzwischen geändert. Bitte neu laden.',
conflict: true conflict: true
}; };
if (code === 'SHOW_COMPLETED') if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' }; return { status: 409, error: 'Abgeschlossene Streams können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY') if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' }; return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND') if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' }; return { status: 409, error: 'Das zugehörige Piece-Original ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if ( if (
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes( ['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
@@ -84,7 +86,7 @@ function actionError(cause: unknown) {
return { return {
status: 409, status: 409,
error: error:
'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.' 'Diese Streamfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
}; };
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
@@ -110,8 +112,24 @@ function attempt(intent: string, operation: () => unknown) {
export const load: PageServerLoad = ({ locals, params, url }) => { export const load: PageServerLoad = ({ locals, params, url }) => {
const actor = requireUser(locals.user); const actor = requireUser(locals.user);
const show = getShow(params.id); const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden'); if (!show) error(404, 'Stream nicht gefunden');
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : []; const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
const navigationShows = listShows();
const streamNavigation = {
query: '',
state: '',
counts: {
all: navigationShows.length,
created: navigationShows.filter((item) => item.status === 'draft' && item.sectionCount === 0)
.length,
preparing: navigationShows.filter((item) => item.status === 'draft' && item.sectionCount > 0)
.length,
ready: navigationShows.filter((item) => item.status === 'ready' || item.status === 'live')
.length,
completed: navigationShows.filter((item) => item.status === 'completed').length
},
shows: navigationShows.filter((item) => item.status !== 'completed').slice(0, 6)
};
if (show.status === 'completed') { if (show.status === 'completed') {
return { return {
show, show,
@@ -125,7 +143,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
pieceTemplates: [], pieceTemplates: [],
createCommandId: randomUUID(), createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false } filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false },
streamNavigation
}; };
} }
const pieces = listPieces({ const pieces = listPieces({
@@ -154,7 +173,8 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
categoryId: url.searchParams.get('category') ?? '', categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1', includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1' includePlanned: url.searchParams.get('includePlanned') === '1'
} },
streamNavigation
}; };
}; };
@@ -350,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 }) => { duplicateUsage: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+177 -170
View File
@@ -8,12 +8,22 @@
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ShareManagement from '#lib/components/ShareManagement.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 { formatClock, formatDuration } from '#lib/format.js';
import type { PieceUsage, ShowSection } from '#lib/server/shows.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); 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<InspectorSelection>(
initialUsageId ? { kind: 'usage', id: initialUsageId } : { kind: 'show' }
);
let dragged = $state<{ kind: 'piece' | 'section' | 'usage'; id: string } | null>(null); let dragged = $state<{ kind: 'piece' | 'section' | 'usage'; id: string } | null>(null);
let dropPreview = $state< let dropPreview = $state<
| { kind: 'section'; position: number } | { kind: 'section'; position: number }
@@ -44,6 +54,26 @@
let plannedCount = $derived( let plannedCount = $derived(
data.show.sections.reduce((total, section) => total + section.usages.length, 0) 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(() => { onMount(() => {
const syncFiltersFromHistory = () => { const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams; const search = new URL(window.location.href).searchParams;
@@ -86,6 +116,18 @@
dropTarget = null; 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() { function draggedTitle() {
if (!dragged) return ''; if (!dragged) return '';
const draggedId = dragged.id; const draggedId = dragged.id;
@@ -171,7 +213,7 @@
function removeDraggedUsage() { function removeDraggedUsage() {
if (dragged?.kind !== 'usage') return; if (dragged?.kind !== 'usage') return;
if (!confirm('Piece aus dieser Sendung entfernen?')) { if (!confirm('Piece aus diesem Stream entfernen?')) {
finishDrag(); finishDrag();
return; return;
} }
@@ -182,7 +224,7 @@
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => { const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
const affected = formElement.closest<HTMLElement>( const affected = formElement.closest<HTMLElement>(
'.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'); affected?.classList.add('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = true; if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
@@ -204,14 +246,16 @@
} }
</script> </script>
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>{data.show.title} | Streams | Sendewerk</title></svelte:head>
{#if data.historical} {#if data.historical}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap historical-show-page"> <div class="page-wrap historical-show-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a> <a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a>
<p class="eyebrow">Unveränderliche Sendungshistorie</p> <p class="eyebrow">Unveränderliche Streamhistorie</p>
<h1>{data.show.title}</h1> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -224,7 +268,7 @@
</div> </div>
<form method="POST" action="?/duplicateShow"> <form method="POST" action="?/duplicateShow">
<input type="hidden" name="commandId" value={data.duplicateCommandId} /> <input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button> <button class="secondary-button" type="submit">Als neuen Stream duplizieren</button>
</form> </form>
</div> </div>
</header> </header>
@@ -300,11 +344,14 @@
</section>{/each} </section>{/each}
</div> </div>
</div> </div>
</div>
{:else} {:else}
<div class="stream-detail-workspace">
<StreamNavigation {...data.streamNavigation} />
<div class="page-wrap builder-page"> <div class="page-wrap builder-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a <a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Streams</a
> >
<h1>{data.show.title}</h1> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
@@ -336,60 +383,6 @@
</div>{/if} </div>{/if}
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div> <div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings">
<summary>Sendungsdaten und Twitch-Texte</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/saveMetadata"
class="show-settings-form"
>
<input type="hidden" name="revision" value={data.show.revision} />
<label class="wide-field"
>Interner Titel<input
name="title"
required
maxlength="200"
value={data.show.title}
/></label
>
<label
>Termin<input name="startsAt" type="datetime-local" value={data.startsAtLocal} /><small
>Die Anzeige erfolgt immer in Europe/Berlin.</small
></label
>
<label
>Status<select name="status" value={data.show.status}
><option value="draft">Entwurf</option><option value="ready">Bereit</option><option
value="live">Live</option
></select
></label
>
<label class="wide-field"
>Twitch-Titel<span class="copy-field"
><input name="twitchTitle" maxlength="200" value={data.show.twitchTitle} /><button
type="button"
onclick={() => copyText('Twitch-Titel', data.show.twitchTitle)}>Kopieren</button
></span
></label
>
<label class="wide-field"
>Ankündigung<span class="copy-field"
><textarea name="announcementText" maxlength="20000" rows="4"
>{data.show.announcementText}</textarea
><button
type="button"
onclick={() => copyText('Ankündigung', data.show.announcementText)}>Kopieren</button
></span
></label
>
<div class="wide-field settings-submit">
<span>{copyMessage}</span><button class="primary-button" type="submit"
>Sendungsdaten speichern</button
>
</div>
</form>
</details>
{#if data.canManageShares}<ShareManagement {#if data.canManageShares}<ShareManagement
shows={[data.show]} shows={[data.show]}
shares={data.shares} shares={data.shares}
@@ -402,7 +395,10 @@
class:active={mobilePane === 'fundus'} class:active={mobilePane === 'fundus'}
aria-pressed={mobilePane === 'fundus'} aria-pressed={mobilePane === 'fundus'}
type="button" type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button onclick={() => {
mobilePane = 'fundus';
pieceLibraryOpen = true;
}}>Pieces</button
> >
<button <button
class:active={mobilePane === 'plan'} class:active={mobilePane === 'plan'}
@@ -410,11 +406,18 @@
type="button" type="button"
onclick={() => (mobilePane = 'plan')}>Ablauf</button onclick={() => (mobilePane = 'plan')}>Ablauf</button
> >
<button
class:active={mobilePane === 'details'}
aria-pressed={mobilePane === 'details'}
type="button"
onclick={() => (mobilePane = 'details')}>Details</button
>
</div> </div>
<div class="builder-workspace"> <div class:library-open={pieceLibraryOpen} class="builder-workspace planning-workspace">
<section <section
class="builder-pane source-pane" class="builder-pane source-pane"
class:library-open={pieceLibraryOpen}
class:drop-ready={dropTarget === 'source'} class:drop-ready={dropTarget === 'source'}
class:mobile-hidden={mobilePane !== 'fundus'} class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title" aria-labelledby="source-title"
@@ -431,12 +434,22 @@
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Quelle</p> <p class="eyebrow">Quelle</p>
<h2 id="source-title">Planbarer Fundus</h2> <h2 id="source-title">Planbare Pieces</h2>
</div>
<div class="pane-heading-actions">
<span>{visiblePieces.length}</span><button
class="dialog-close"
type="button"
aria-label="Piece-Auswahl schließen"
onclick={() => {
pieceLibraryOpen = false;
mobilePane = 'plan';
}}>×</button
>
</div> </div>
<span>{visiblePieces.length}</span>
</header> </header>
{#if dropTarget === 'source'}<div class="drag-remove-target"> {#if dropTarget === 'source'}<div class="drag-remove-target">
<strong>Aus Sendung entfernen</strong><span>Das Fundus-Original bleibt erhalten.</span> <strong>Aus Stream entfernen</strong><span>Das Piece-Original bleibt erhalten.</span>
</div>{/if} </div>{/if}
<details class="plan-piece-create"> <details class="plan-piece-create">
<summary>Neues Piece direkt einplanen</summary> <summary>Neues Piece direkt einplanen</summary>
@@ -464,11 +477,11 @@
</details> </details>
<form id="builder-filters" method="GET" class="builder-filter"> <form id="builder-filters" method="GET" class="builder-filter">
<label <label
><span class="sr-only">Fundus durchsuchen</span><input ><span class="sr-only">Pieces durchsuchen</span><input
type="search" type="search"
name="q" name="q"
value={data.filters.query} value={data.filters.query}
placeholder="Fundus durchsuchen" placeholder="Pieces durchsuchen"
/></label /></label
> >
<label <label
@@ -482,7 +495,7 @@
> >
<button class="text-button" type="submit">Filtern</button> <button class="text-button" type="submit">Filtern</button>
</form> </form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern"> <div class="builder-filter-options" aria-label="Piece-Auswahl erweitern">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input <input
type="checkbox" type="checkbox"
@@ -523,7 +536,8 @@
> >
<div> <div>
<span class="category-label" <span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span ><i></i>{piece.categories.map((item) => item.name).join(', ') ||
'Offen'}</span
><span>{formatClock(piece.durationSeconds)}</span> ><span>{formatClock(piece.durationSeconds)}</span>
</div> </div>
<h3> <h3>
@@ -535,7 +549,8 @@
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || {piece.format} · {piece.topics.map((item) => item.name).join(', ') ||
'Ohne Thema'} 'Ohne Thema'}
</p> </p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if} {#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span
>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small> <small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div> </div>
@@ -569,8 +584,8 @@
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span> <span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2> <h2>Keine verfügbaren Pieces</h2>
<p> <p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Bereich Pieces
markieren. sendefertig markieren.
</p> </p>
</div>{/if} </div>{/if}
</section> </section>
@@ -582,10 +597,21 @@
> >
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Sendungsplan</p> <p class="eyebrow">Streamplan</p>
<h2 id="run-title">Ablauf</h2> <h2 id="run-title">Ablauf</h2>
</div> </div>
<div class="pane-heading-actions">
<span>{formatDuration(data.show.totalDurationSeconds)}</span> <span>{formatDuration(data.show.totalDurationSeconds)}</span>
<button
class="secondary-button piece-library-button"
type="button"
aria-expanded={pieceLibraryOpen}
onclick={() => {
pieceLibraryOpen = true;
mobilePane = 'fundus';
}}><AppIcon name="plus" /> Pieces auswählen</button
>
</div>
</header> </header>
<div class="run-list"> <div class="run-list">
{#each data.show.sections as section, index (section.id)} {#each data.show.sections as section, index (section.id)}
@@ -603,7 +629,10 @@
animate:flip={{ duration: 180 }} animate:flip={{ duration: 180 }}
> >
{#if dropPreview?.kind === 'section' && dropPreview.position === index} {#if dropPreview?.kind === 'section' && dropPreview.position === index}
<div class="drag-insert-preview section-insert-preview" data-drop-preview="section"> <div
class="drag-insert-preview section-insert-preview"
data-drop-preview="section"
>
<span>Hier einfügen</span><strong>{draggedTitle()}</strong> <span>Hier einfügen</span><strong>{draggedTitle()}</strong>
</div> </div>
{/if} {/if}
@@ -611,6 +640,8 @@
class="run-section editable-run-section" class="run-section editable-run-section"
class:is-dragging={dragged?.kind === 'section' && dragged.id === section.id} class:is-dragging={dragged?.kind === 'section' && dragged.id === section.id}
class:drop-ready={dropTarget === section.id} class:drop-ready={dropTarget === section.id}
class:selected={inspectorSelection.kind === 'section' &&
inspectorSelection.id === section.id}
style={`--category:${section.categoryColor ?? '#8b8f98'}`} style={`--category:${section.categoryColor ?? '#8b8f98'}`}
ondragover={(event) => { ondragover={(event) => {
if (dragged?.kind === 'section') previewSection(event, index); if (dragged?.kind === 'section') previewSection(event, index);
@@ -620,9 +651,15 @@
> >
<header <header
class="section-drag-handle" class="section-drag-handle"
role="group" role="button"
aria-label={`${section.name} verschieben`} tabindex="0"
aria-label={`${section.name} auswählen oder verschieben`}
aria-pressed={inspectorSelection.kind === 'section' &&
inspectorSelection.id === section.id}
draggable="true" draggable="true"
onclick={() => showInspector({ kind: 'section', id: section.id })}
onkeydown={(event) =>
selectOnKeyboard(event, { kind: 'section', id: section.id })}
ondragstart={(event) => startDrag('section', section.id, event)} ondragstart={(event) => startDrag('section', section.id, event)}
ondragend={finishDrag} ondragend={finishDrag}
> >
@@ -685,6 +722,8 @@
<div <div
class="usage-row editable-usage" class="usage-row editable-usage"
class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id} class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id}
class:selected={inspectorSelection.kind === 'usage' &&
inspectorSelection.id === usage.id}
role="group" role="group"
aria-label={`Verwendung ${usage.title}`} aria-label={`Verwendung ${usage.title}`}
ondragover={(event) => previewUsage(event, section.id, usageIndex)} ondragover={(event) => previewUsage(event, section.id, usageIndex)}
@@ -695,9 +734,13 @@
> >
<div <div
class="usage-drag-copy" class="usage-drag-copy"
role="group" role="button"
aria-label={`${usage.title} verschieben`} tabindex="0"
aria-label={`${usage.title} auswählen oder verschieben`}
draggable="true" draggable="true"
onclick={() => showInspector({ kind: 'usage', id: usage.id })}
onkeydown={(event) =>
selectOnKeyboard(event, { kind: 'usage', id: usage.id })}
ondragstart={(event) => { ondragstart={(event) => {
event.stopPropagation(); event.stopPropagation();
startDrag('usage', usage.id, event); startDrag('usage', usage.id, event);
@@ -749,15 +792,13 @@
><AppIcon name="arrow-down" /></button ><AppIcon name="arrow-down" /></button
> >
</form> </form>
<UsageEditorDialog <button
{usage} class="usage-edit-button"
open={page.url.searchParams.get('usage') === usage.id} type="button"
revision={data.show.revision} aria-label={`${usage.title} bearbeiten und verschieben`}
sectionId={section.id} onclick={() => showInspector({ kind: 'usage', id: usage.id })}
sections={data.show.sections} ><AppIcon name="pencil" /></button
error={form?.error} >
{enhanceCommand}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -791,70 +832,9 @@
dropAtPreview(); dropAtPreview();
}} }}
> >
Leerer Abschnitt · Piece aus dem Fundus hinzufügen oder hierher ziehen Leerer Abschnitt · Piece hinzufügen oder hierher ziehen
</div> </div>
{/if} {/if}
<details class="section-editor">
<summary>Abschnitt bearbeiten</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateSection"
class="section-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><label
>Name<input
name="name"
required
maxlength="200"
value={section.name}
/></label
><label
>Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={section.categoryId === category.id}>{category.name}</option
>{/each}</select
></label
><label
>Explizite Dauer in Sekunden<input
name="explicitDurationSeconds"
type="number"
min="0"
max="86400"
value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces"
/></label
><label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
value={section.twitchTags.join(', ')}
placeholder="Gaming, Deutsch"
/></label
><button class="secondary-button" type="submit">Abschnitt speichern</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeSection"
onsubmit={(event) =>
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="sectionId"
value={section.id}
/><button class="text-button danger-text" type="submit"
>Abschnitt entfernen</button
>
</form>
</details>
</article> </article>
</div> </div>
{/each} {/each}
@@ -878,12 +858,17 @@
class="add-section-form" class="add-section-form"
> >
<input type="hidden" name="revision" value={data.show.revision} /><label <input type="hidden" name="revision" value={data.show.revision} /><label
>Name<input name="name" required maxlength="200" placeholder="Neuer Abschnitt" /></label >Name<input
name="name"
required
maxlength="200"
placeholder="Neuer Abschnitt"
/></label
><label ><label
>Kategorie<select name="categoryId" >Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option ><option value="">Ohne Kategorie</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id} >{#each data.taxonomy.categories as category (category.id)}<option
>{category.name}</option value={category.id}>{category.name}</option
>{/each}</select >{/each}</select
></label ></label
><label ><label
@@ -943,6 +928,8 @@
<div <div
class="reserve-usage" class="reserve-usage"
class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id} class:is-dragging={dragged?.kind === 'usage' && dragged.id === usage.id}
class:selected={inspectorSelection.kind === 'usage' &&
inspectorSelection.id === usage.id}
role="group" role="group"
aria-label={`Reserve ${usage.title}`} aria-label={`Reserve ${usage.title}`}
ondragover={(event) => previewUsage(event, null, usageIndex)} ondragover={(event) => previewUsage(event, null, usageIndex)}
@@ -953,13 +940,17 @@
> >
<div <div
class="usage-drag-copy" class="usage-drag-copy"
role="group" role="button"
aria-label={`${usage.title} verschieben`} tabindex="0"
aria-label={`${usage.title} auswählen oder verschieben`}
draggable="true" draggable="true"
onclick={() => showInspector({ kind: 'usage', id: usage.id })}
onkeydown={(event) => selectOnKeyboard(event, { kind: 'usage', id: usage.id })}
ondragstart={(event) => startDrag('usage', usage.id, event)} ondragstart={(event) => startDrag('usage', usage.id, event)}
ondragend={finishDrag} ondragend={finishDrag}
> >
<strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small> <strong>{usage.title}</strong><small>{formatClock(usage.durationSeconds)}</small
>
</div> </div>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input <input type="hidden" name="revision" value={data.show.revision} /><input
@@ -971,8 +962,8 @@
name="sectionId" name="sectionId"
required required
><option value="">Abschnitt wählen</option ><option value="">Abschnitt wählen</option
>{#each data.show.sections as section (section.id)}<option value={section.id} >{#each data.show.sections as section (section.id)}<option
>{section.name}</option value={section.id}>{section.name}</option
>{/each}</select >{/each}</select
></label ></label
><button class="secondary-button" type="submit">Einplanen</button> ><button class="secondary-button" type="submit">Einplanen</button>
@@ -982,7 +973,7 @@
method="POST" method="POST"
action="?/removeUsage" action="?/removeUsage"
onsubmit={(event) => onsubmit={(event) =>
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()} !confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
> >
<input type="hidden" name="revision" value={data.show.revision} /><input <input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden" type="hidden"
@@ -991,18 +982,17 @@
/><button /><button
class="text-button danger-text" class="text-button danger-text"
type="submit" type="submit"
aria-label={`${usage.title} aus Sendung entfernen`} aria-label={`${usage.title} aus Stream entfernen`}
>Aus Sendung entfernen</button >Aus Stream entfernen</button
> >
</form> </form>
<UsageEditorDialog <button
{usage} class="usage-edit-button"
revision={data.show.revision} type="button"
sectionId="" aria-label={`${usage.title} bearbeiten und verschieben`}
sections={data.show.sections} onclick={() => showInspector({ kind: 'usage', id: usage.id })}
error={form?.error} ><AppIcon name="pencil" /></button
{enhanceCommand} >
/>
</div> </div>
</div> </div>
{/each} {/each}
@@ -1016,6 +1006,22 @@
</p>{/if} </p>{/if}
</section> </section>
</section> </section>
<div class="stream-inspector-slot" class:mobile-hidden={mobilePane !== 'details'}>
{#key `${inspectorSelection.kind}-${inspectorSelection.kind === 'show' ? data.show.id : inspectorSelection.id}`}
<StreamInspector
show={data.show}
startsAtLocal={data.startsAtLocal}
categories={data.taxonomy.categories}
section={selectedSection}
usage={selectedUsageContext?.usage ?? null}
usageSectionName={selectedUsageContext?.section?.name ?? ''}
usageSectionId={selectedUsageContext?.section?.id ?? ''}
{enhanceCommand}
{copyText}
onshow={() => showInspector({ kind: 'show' })}
/>
{/key}
</div>
</div> </div>
<form <form
@@ -1072,4 +1078,5 @@
<input type="hidden" name="usageId" value={removedUsageId} /> <input type="hidden" name="usageId" value={removedUsageId} />
</form> </form>
</div> </div>
</div>
{/if} {/if}
+34
View File
@@ -0,0 +1,34 @@
import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { listGlobalSearchFacets, searchWorkspace } from '#lib/server/global-search.js';
import type { GlobalSearchFilters } from '#lib/server/global-search.js';
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const query = (url.searchParams.get('q') ?? '').trim().slice(0, 200);
const rawType = url.searchParams.get('type');
const filters: GlobalSearchFilters & {
type: NonNullable<GlobalSearchFilters['type']>;
status: string;
topicId: string;
formatId: string;
} = {
type: rawType === 'pieces' || rawType === 'shows' ? rawType : ('all' as const),
status: (url.searchParams.get('status') ?? '').slice(0, 30),
topicId: (url.searchParams.get('topic') ?? '').slice(0, 100),
formatId: (url.searchParams.get('format') ?? '').slice(0, 100)
};
const results = searchWorkspace(query, filters);
const countResults =
filters.type === 'all' ? results : searchWorkspace(query, { ...filters, type: 'all' });
return {
query,
filters,
facets: listGlobalSearchFacets(),
results,
counts: {
pieces: countResults.pieces.length,
shows: countResults.shows.length
}
};
};
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import AppIcon from '#lib/components/AppIcon.svelte';
let { data } = $props();
let filtersOpen = $state(false);
const statusLabels: Record<string, string> = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Bereit',
archived: 'Archiviert',
draft: 'Angelegt',
live: 'Live',
completed: 'Abgeschlossen'
};
let pieceCount = $derived(data.results.pieces.length);
let showCount = $derived(data.results.shows.length);
let totalCount = $derived(pieceCount + showCount);
let allCount = $derived(data.counts.pieces + data.counts.shows);
let activeFilterCount = $derived(
Number(Boolean(data.filters.status)) +
Number(Boolean(data.filters.topicId)) +
Number(Boolean(data.filters.formatId))
);
function typeHref(type: 'all' | 'pieces' | 'shows') {
const params = new SvelteURLSearchParams();
if (data.query) params.set('q', data.query);
if (type !== 'all') params.set('type', type);
if (data.filters.status) params.set('status', data.filters.status);
if (data.filters.topicId) params.set('topic', data.filters.topicId);
if (data.filters.formatId) params.set('format', data.filters.formatId);
return `${resolve('/suche')}?${params}`;
}
</script>
<svelte:head><title>Suche | Sendewerk</title></svelte:head>
<section class="page-wrap search-page">
<header class="search-page-heading">
<div>
<p class="eyebrow">Arbeitsbereich</p>
<h1>Suche</h1>
<p>Pieces und Streams durchsuchen, auch wenn sie archiviert oder abgeschlossen sind.</p>
</div>
<button
class="secondary-button search-filter-trigger"
type="button"
aria-expanded={filtersOpen}
aria-controls="search-filters"
onclick={() => (filtersOpen = !filtersOpen)}
>
<AppIcon name="sliders" /> Filter{activeFilterCount ? ` · ${activeFilterCount}` : ''}
</button>
</header>
<form class="search-page-form" method="GET" action={resolve('/suche')} role="search">
<input type="hidden" name="type" value={data.filters.type === 'all' ? '' : data.filters.type} />
<input type="hidden" name="status" value={data.filters.status} />
<input type="hidden" name="topic" value={data.filters.topicId} />
<input type="hidden" name="format" value={data.filters.formatId} />
<label>
<span class="sr-only">Suchbegriff</span>
<AppIcon name="magnifying-glass" />
<input
name="q"
type="search"
value={data.query}
minlength="2"
placeholder="Titel, Recherche, Ablauf …"
/>
</label>
<button class="primary-button" type="submit">Suchen</button>
</form>
<div class="search-workspace">
<aside class:open={filtersOpen} class="search-filter-panel" id="search-filters">
<header>
<div>
<p class="eyebrow">Eingrenzen</p>
<h2>Filter</h2>
</div>
<button
class="dialog-close"
type="button"
aria-label="Filter schließen"
onclick={() => (filtersOpen = false)}>×</button
>
</header>
<nav aria-label="Ergebnisart">
<!-- Links use the resolved route and controlled filter parameters. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a class:active={data.filters.type === 'all'} href={typeHref('all')}
><span>Alle Ergebnisse</span><strong>{allCount}</strong></a
>
<a class:active={data.filters.type === 'pieces'} href={typeHref('pieces')}
><span>Pieces</span><strong>{data.counts.pieces}</strong></a
>
<a class:active={data.filters.type === 'shows'} href={typeHref('shows')}
><span>Streams</span><strong>{data.counts.shows}</strong></a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</nav>
<form method="GET" action={resolve('/suche')} class="search-facet-form">
<input type="hidden" name="q" value={data.query} />
<input
type="hidden"
name="type"
value={data.filters.type === 'all' ? '' : data.filters.type}
/>
<label
>Status<select name="status" value={data.filters.status}>
<option value="">Alle Status</option>
<option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
><option value="ready">Bereit</option><option value="archived">Archiviert</option
><option value="draft">Angelegt</option><option value="live">Live</option><option
value="completed">Abgeschlossen</option
>
</select></label
>
<label
>Piece-Thema<select name="topic" value={data.filters.topicId}>
<option value="">Alle Themen</option
>{#each data.facets.topics as topic (topic.id)}<option value={topic.id}
>{topic.name}</option
>{/each}
</select></label
>
<label
>Piece-Format<select name="format" value={data.filters.formatId}>
<option value="">Alle Formate</option
>{#each data.facets.formats as format (format.id)}<option value={format.id}
>{format.name}</option
>{/each}
</select></label
>
<button class="secondary-button" type="submit">Filter anwenden</button>
<!-- The query is encoded and appended to the resolved internal search route. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="text-button" href={`${resolve('/suche')}?q=${encodeURIComponent(data.query)}`}
>Filter zurücksetzen</a
>
</form>
</aside>
<main class="search-results" aria-live="polite">
{#if data.query.length === 1}
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
{:else if data.query}
<header class="search-results-heading">
<div>
<p class="eyebrow">Treffer</p>
<h2>{totalCount} Ergebnisse</h2>
</div>
{#if activeFilterCount}<span>{activeFilterCount} aktive Filter</span>{/if}
</header>
{#each [['pieces', 'Pieces'], ['shows', 'Streams']] as group (group[0])}
{@const results = data.results[group[0] as 'pieces' | 'shows']}
{#if results.length}<section
class="search-result-group"
aria-labelledby={`${group[0]}-results`}
>
<header class="result-group-heading">
<h2 id={`${group[0]}-results`}>{group[1]}</h2>
<span>{results.length}</span>
</header>
<div class="search-result-list">
{#each results as result (result.id)}
<!-- Search results contain only server-generated internal paths. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={result.href}
><span class="result-title"
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
>{statusLabels[result.status] ?? result.status}</span
></span
><small>{result.matchLabel}</small>
<p>{result.excerpt}</p></a
>
{/each}
</div>
</section>{/if}
{/each}
{#if !totalCount}<div class="empty-state">
<h2>Keine passenden Ergebnisse</h2>
<p>Suchbegriff oder Filter anpassen.</p>
</div>{/if}
{:else}<div class="search-start-state">
<AppIcon name="magnifying-glass" />
<h2>Was suchst du?</h2>
<p>Durchsuche Titel, Streamtexte, Recherche, Quellen und Abläufe.</p>
</div>{/if}
</main>
</div>
</section>
+1 -1
View File
@@ -275,7 +275,7 @@
</button> </button>
</div> </div>
<p class="catalog-note"> <p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten. Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Streams erhalten.
</p> </p>
<div class="catalog-settings-list tool-settings-list"> <div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)} {#each data.tools as tool (tool.id)}
+742 -744
View File
File diff suppressed because it is too large. Load diff