diff --git a/CloudronManifest.json b/CloudronManifest.json index a1f09c1..1a20f48 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.8.0", + "version": "0.8.1", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/README.md b/README.md index 2447cd8..af63adc 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Zielressource erneut. MCP kann keine Inhalte schreiben. ### In ChatGPT einrichten -1. Zuerst Version `0.8.0` 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 ChatGPT-Verbindung nicht erreichbar. 2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d4665cb..76d1be6 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,5 +1,24 @@ # Abnahme +## 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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4608352..8b5c47c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,6 +54,18 @@ rechte Arbeitsleiste; ohne ausgewählte Usage wird es nicht gerendert. Unterhalb der mobilen Grenze öffnet dieselbe Komponente modal und nutzt den verfügbaren Viewport. +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-, Planungs- und Live-Arbeitsflächen ändern ausschließlich ihre +Darstellung. Im Streamplan ist der Ablauf die Hauptfläche und die Piece-Auswahl +eine bei Bedarf geöffnete Nebenspalte. Dieselben Formulare, Revisionen, +Drag-Daten, Fokusgrenzen und mobilen Pane-/Sheet-Zustände bleiben die +Interaktionsgrenze. + ## Laufzeit und Speicher - Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4d8e28f..13effdd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -620,3 +620,17 @@ 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. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 1000057..ab462a0 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,35 @@ # Release Notes +## 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 diff --git a/docs/STATUS.md b/docs/STATUS.md index c71ec7b..de31c97 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,6 +8,34 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie, Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind dokumentiert. +## 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 diff --git a/package-lock.json b/package-lock.json index e2dd9c7..6365b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@node-rs/argon2": "^2.2.2", "@tiptap/core": "^3.32.0", diff --git a/package.json b/package.json index 514aee1..7fb464b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.8.0", + "version": "0.8.1", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/src/app.css b/src/app.css index 3e74c3c..b8b3691 100644 --- a/src/app.css +++ b/src/app.css @@ -47,6 +47,418 @@ --radius-small: 0.65rem; } +/* 0.8.1 editorial workspaces */ +.fundus-editor-surface .piece-script-panel > .piece-panel-heading { + display: none; +} + +.fundus-editor-surface .piece-script-panel > .piece-writing-stack { + grid-row: 1 / span 2; + width: min(100%, 48rem); + justify-self: center; +} + +.fundus-editor-surface .piece-writing-stack { + gap: 1rem; +} + +.fundus-editor-surface .piece-writing-stack .rich-markdown-surface { + min-height: 7rem; + border-color: color-mix(in srgb, var(--gold) 22%, var(--line-strong)); + border-radius: 0.8rem; + background: color-mix(in srgb, var(--field) 96%, var(--gold) 4%); +} + +.fundus-editor-surface + .piece-writing-stack + .rich-markdown-field:nth-child(2) + .rich-markdown-surface { + min-height: 12rem; +} + +.fundus-editor-surface .piece-meta-details, +.fundus-editor-surface .piece-presentation-panel, +.fundus-editor-surface .piece-media-panel { + border-color: color-mix(in srgb, var(--gold) 22%, var(--line)); +} + +.fundus-editor-surface .piece-presentation-panel { + padding: 0.9rem; + border: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line)); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--material-inset) 84%, transparent); +} + +.planning-workspace { + grid-template-columns: minmax(0, 1fr); +} + +.planning-workspace > .source-pane { + display: none; +} + +.planning-workspace.library-open { + grid-template-columns: minmax(18rem, 0.62fr) minmax(32rem, 1.7fr); +} + +.planning-workspace > .source-pane.library-open { + display: block; +} + +.planning-workspace .run-pane { + width: min(100%, 74rem); + justify-self: center; + padding: clamp(1rem, 2vw, 1.5rem); +} + +.planning-workspace.library-open .run-pane { + width: 100%; +} + +.pane-heading-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.pane-heading-actions > span { + padding: 0.3rem 0.55rem; + border-radius: 2rem; + background: var(--material-deep); + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 680; +} + +.piece-library-button { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.planning-workspace .run-section { + padding: 0; + overflow: hidden; + border-color: color-mix(in srgb, var(--category) 24%, var(--line)); + background: color-mix(in srgb, var(--material-inset) 90%, transparent); +} + +.planning-workspace .section-drag-handle { + padding: 0.8rem 7rem 0.8rem 0.8rem; + border-left: 0.22rem solid var(--category); + background: color-mix(in srgb, var(--category) 7%, transparent); +} + +.planning-workspace .usage-list { + gap: 0; + margin: 0; + border-top: 1px solid var(--line); +} + +.planning-workspace .usage-row { + min-height: 3rem; + padding: 0.55rem 0.8rem 0.55rem 1rem; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: color-mix(in srgb, var(--material-deep) 74%, transparent); +} + +.planning-workspace .usage-drag-copy { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.planning-workspace .section-placeholder, +.planning-workspace .section-editor { + margin-inline: 1rem; +} + +.planning-workspace .section-editor { + margin-bottom: 0.7rem; +} + +.live-current { + border-color: color-mix(in srgb, var(--gold) 26%, var(--line)); + background: color-mix(in srgb, var(--glass) 94%, var(--bg)); +} + +.live-current-scroll { + padding-inline: clamp(1rem, 4vw, 4.5rem); +} + +.live-piece-heading { + grid-template-columns: minmax(0, 1fr) auto; + padding-bottom: 0.9rem; +} + +.live-piece-context { + display: flex; + grid-column: 1 / -1; + align-items: center; + gap: 0.45rem; + color: var(--gold-strong); + font-size: 0.62rem; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.live-piece-context span + span { + padding-left: 0.45rem; + border-left: 1px solid var(--line-strong); + color: var(--text-faint); +} + +.live-script-section { + max-width: 64rem; + padding-block: 1rem; +} + +.live-intro .prompter-copy { + font-size: calc(var(--prompt-size) * 1.08); +} + +.moderation-card { + margin-block: 0.35rem; + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--gold) 22%, var(--line)); + border-radius: 0.85rem; + background: color-mix(in srgb, var(--gold) 5%, var(--material-inset)); +} + +.search-page { + width: min(100%, 92rem); +} + +.search-page-heading, +.search-results-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; +} + +.search-page-heading h1, +.search-results-heading h2 { + margin: 0.1rem 0 0; +} + +.search-page-heading p:not(.eyebrow) { + margin: 0.35rem 0 0; + color: var(--text-muted); +} + +.search-filter-trigger { + display: none; +} + +.search-workspace { + display: grid; + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.search-filter-panel { + position: sticky; + top: 5.25rem; + padding: 0.9rem; + border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line)); + border-radius: var(--radius-large); + background: color-mix(in srgb, var(--glass) 94%, transparent); + box-shadow: var(--shadow-soft); +} + +.search-filter-panel > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.search-filter-panel h2 { + margin: 0.1rem 0 0; + font-size: 1.1rem; +} + +.search-filter-panel .dialog-close { + display: none; +} + +.search-filter-panel nav { + display: grid; + gap: 0.2rem; + padding-bottom: 0.8rem; + border-bottom: 1px solid var(--line); +} + +.search-filter-panel nav a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.65rem; + border: 1px solid transparent; + border-radius: 0.65rem; + color: var(--text-muted); + font-size: 0.75rem; +} + +.search-filter-panel nav a.active { + border-color: color-mix(in srgb, var(--gold) 36%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--material-inset)); + color: var(--text); +} + +.search-filter-panel nav strong { + color: var(--gold-strong); + font-size: 0.68rem; +} + +.search-facet-form { + display: grid; + gap: 0.65rem; + margin-top: 0.85rem; +} + +.search-facet-form label { + display: grid; + gap: 0.25rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.search-facet-form select { + width: 100%; +} + +.search-facet-form .text-button { + text-align: center; +} + +.search-results { + min-width: 0; +} + +.search-results-heading { + padding: 0.35rem 0 0.8rem; + border-bottom: 1px solid var(--line); +} + +.search-results-heading > span { + padding: 0.3rem 0.55rem; + border-radius: 999px; + background: color-mix(in srgb, var(--gold) 10%, var(--panel)); + color: var(--gold-strong); + font-size: 0.68rem; +} + +.search-result-group { + margin-top: 1.2rem; +} + +.search-result-list > a { + padding: 1rem 1.1rem; + border-radius: 0.85rem; + background: color-mix(in srgb, var(--glass) 84%, transparent); +} + +.search-start-state { + display: grid; + min-height: 22rem; + place-items: center; + align-content: center; + text-align: center; + color: var(--text-muted); +} + +.search-start-state .app-icon { + font-size: 2rem; + color: var(--gold-strong); +} + +.search-start-state h2, +.search-start-state p { + margin: 0.45rem 0 0; +} + +@media (max-width: 72rem) { + .planning-workspace, + .planning-workspace.library-open { + display: block; + } + + .planning-workspace > .source-pane, + .planning-workspace > .source-pane.library-open { + display: block; + } + + .planning-workspace .piece-library-button, + .planning-workspace .source-pane .pane-heading-actions .dialog-close { + display: none; + } +} + +@media (max-width: 52rem) { + .fundus-editor-surface .piece-script-panel > .piece-writing-stack { + width: 100%; + } + + .search-page { + padding-inline: 1rem; + } + + .search-page-heading { + align-items: flex-start; + } + + .search-filter-trigger { + display: flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + } + + .search-workspace { + display: block; + } + + .search-filter-panel { + display: none; + position: fixed; + z-index: 80; + inset: calc(var(--mobile-header) + env(safe-area-inset-top) + 0.4rem) 0.5rem + calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 0.4rem); + overflow-y: auto; + background: var(--bg-elevated); + box-shadow: var(--shadow-large); + } + + .search-filter-panel.open { + display: block; + } + + .search-filter-panel .dialog-close { + display: grid; + } + + .search-page-form { + margin-block: 1rem; + } + + .live-current-scroll { + padding-inline: 0.9rem; + } + + .moderation-card { + padding: 0.8rem; + } +} + .completion-dialog { width: min(38rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); @@ -9146,3 +9558,33 @@ button.catalog-entry-main:hover { flex: 1; } } + +/* Keep the 0.8.1 workspace overrides after the legacy component rules. */ +.fundus-editor-surface .piece-script-panel > .piece-writing-stack { + grid-row: 1 / span 2; + width: min(100%, 48rem); + justify-self: center; +} + +.planning-workspace { + grid-template-columns: minmax(0, 1fr); +} + +.live-current { + border-color: color-mix(in srgb, var(--gold) 26%, var(--line)); + background: color-mix(in srgb, var(--glass) 94%, var(--bg)); +} + +.live-current-scroll { + padding-inline: clamp(1rem, 4vw, 4.5rem); +} + +@media (max-width: 52rem) { + .fundus-editor-surface .piece-script-panel > .piece-writing-stack { + width: 100%; + } + + .live-current-scroll { + padding-inline: 0.9rem; + } +} diff --git a/src/lib/server/global-search.spec.ts b/src/lib/server/global-search.spec.ts index c18a742..43b071b 100644 --- a/src/lib/server/global-search.spec.ts +++ b/src/lib/server/global-search.spec.ts @@ -48,4 +48,18 @@ describe('global workspace search', () => { 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([]); + }); }); diff --git a/src/lib/server/global-search.ts b/src/lib/server/global-search.ts index 5e680c6..9be690d 100644 --- a/src/lib/server/global-search.ts +++ b/src/lib/server/global-search.ts @@ -16,6 +16,18 @@ export type GlobalSearchResults = { 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 { @@ -42,56 +54,89 @@ function match( return null; } -export function searchWorkspace(rawQuery: string): GlobalSearchResults { +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 = ( - database - .prepare( - `SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text, + 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, - group_concat(COALESCE(ps.label, '') || ' ' || COALESCE(ps.url, ''), char(10)) AS sources + 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[] - ) - .map((row): SearchResult | null => { - const found = match( - [ - { label: 'Titel', value: String(row.title) }, - { label: 'Ausgabetitel', value: String(row.output_title) }, - { label: 'Zusammenfassung', value: String(row.summary) }, - { label: 'Anmoderation', value: String(row.intro) }, - { label: 'Recherche', value: String(row.research_text) }, - { label: 'Moderation', value: String(row.moderation_notes) }, - { label: 'Interaktion', value: String(row.interaction_text) }, - { label: 'Übergang', value: String(row.transition_text) }, - { label: 'Präsentation', value: String(row.presentation_text) }, - { label: 'Quellen', value: String(row.sources) } - ], - query - ); - if (!found) return null; - const status = row.status as PieceStatus; - return { - id: String(row.id), - title: String(row.title), - href: status === 'archived' ? `/archiv#piece-${row.id}` : `/fundus/${row.id}`, - status, - ...found - }; - }) - .filter((result): result is SearchResult => result !== null) - .slice(0, 50); + ) + .all() as Record[] + ) + .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 = ( - database - .prepare( - `SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text, + 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, '') || ' ' || @@ -101,30 +146,31 @@ export function searchWorkspace(rawQuery: string): GlobalSearchResults { LEFT JOIN show_sections s ON s.show_id = sh.id LEFT JOIN piece_usages u ON u.show_section_id = s.id GROUP BY sh.id ORDER BY sh.starts_at_utc DESC, sh.created_at_utc DESC` - ) - .all() as Record[] - ) - .map((row): SearchResult | null => { - const found = match( - [ - { label: 'Titel', value: String(row.title) }, - { label: 'Twitch-Titel', value: String(row.twitch_title) }, - { label: 'Ankündigung', value: String(row.announcement_text) }, - { label: 'Ablauf und Pieces', value: String(row.contents) } - ], - query - ); - if (!found) return null; - return { - id: String(row.id), - title: String(row.title), - href: `/sendungen/${row.id}`, - status: row.status as ShowStatus, - ...found - }; - }) - .filter((result): result is SearchResult => result !== null) - .slice(0, 50); + ) + .all() as Record[] + ) + .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 }; } diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index 8a4d989..2dda94f 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -873,6 +873,10 @@ >
+
+ Piece {viewedPosition || '–'} von {data.live.totalCount} + {#if viewedSection}{viewedSection.name}{/if} +

{viewedUsage.title}

{#if showTimes} {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)} diff --git a/src/routes/sendungen/[id]/+page.svelte b/src/routes/sendungen/[id]/+page.svelte index 264bea2..4e26b32 100644 --- a/src/routes/sendungen/[id]/+page.svelte +++ b/src/routes/sendungen/[id]/+page.svelte @@ -15,6 +15,7 @@ let { data, form }: PageProps = $props(); let mobilePane = $state<'fundus' | 'plan'>('plan'); + let pieceLibraryOpen = $state(false); let dragged = $state<{ kind: 'piece' | 'section' | 'usage'; id: string } | null>(null); let dropPreview = $state< | { kind: 'section'; position: number } @@ -408,7 +409,10 @@ class:active={mobilePane === 'fundus'} aria-pressed={mobilePane === 'fundus'} type="button" - onclick={() => (mobilePane = 'fundus')}>Pieces { + mobilePane = 'fundus'; + pieceLibraryOpen = true; + }}>Pieces
-
+
Quelle

Planbare Pieces

- {visiblePieces.length} +
+ {visiblePieces.length} +
{#if dropTarget === 'source'}
Aus Stream entfernenDas Piece-Original bleibt erhalten. @@ -593,7 +608,18 @@

Streamplan

Ablauf

- {formatDuration(data.show.totalDurationSeconds)} +
+ {formatDuration(data.show.totalDurationSeconds)} + +
{#each data.show.sections as section, index (section.id)} diff --git a/src/routes/suche/+page.server.ts b/src/routes/suche/+page.server.ts index 67c8159..16d73ec 100644 --- a/src/routes/suche/+page.server.ts +++ b/src/routes/suche/+page.server.ts @@ -1,9 +1,34 @@ import type { PageServerLoad } from './$types'; import { requireUser } from '#lib/server/auth.js'; -import { searchWorkspace } from '#lib/server/global-search.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); - return { query, results: searchWorkspace(query) }; + const rawType = url.searchParams.get('type'); + const filters: GlobalSearchFilters & { + type: NonNullable; + 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 + } + }; }; diff --git a/src/routes/suche/+page.svelte b/src/routes/suche/+page.svelte index e6ba25f..aae295b 100644 --- a/src/routes/suche/+page.svelte +++ b/src/routes/suche/+page.svelte @@ -1,8 +1,10 @@ Suche | Sendewerk -
-
-

Arbeitsbereich

-

Suche

-

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

+
+
+
+

Arbeitsbereich

+

Suche

+

Pieces und Streams durchsuchen, auch wenn sie archiviert oder abgeschlossen sind.

+
+
+ {#each [['pieces', 'Pieces'], ['shows', 'Streams']] as group (group[0])} + {@const results = data.results[group[0] as 'pieces' | 'shows']} + {#if results.length}
+
+

{group[1]}

+ {results.length} +
+ +
{/if} + {/each} + {#if !totalCount}
+

Keine passenden Ergebnisse

+

Suchbegriff oder Filter anpassen.

+
{/if} + {:else}
+ +

Was suchst du?

+

Durchsuche Titel, Streamtexte, Recherche, Quellen und Abläufe.

+
{/if} + +
diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index 6284c0d..0394336 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -522,8 +522,15 @@ test('dashboard, global search and responsive primary navigation use the shared await expect(page.getByRole('heading', { name: 'Pieces', exact: true })).toBeVisible(); await expect(page.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible(); await expect(page.getByText('Recherche', { exact: true })).toBeVisible(); + await expect(page.locator('.search-filter-panel')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('suche-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('button', { name: 'Filter' }).click(); + await expect(page.locator('.search-filter-panel')).toBeVisible(); + await expect(page.getByLabel('Piece-Thema')).toBeVisible(); + await expectNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath('suche-mobile-filter.png'), fullPage: true }); await page.goto('/'); await expect(page.locator('.app-header')).toBeHidden(); await expect( @@ -723,6 +730,7 @@ test('sent and planned source filters stay independent and removing the last usa await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung'); await page.getByRole('radio', { name: /Standardablauf/ }).check(); await page.getByRole('button', { name: 'Stream anlegen' }).click(); + await page.getByRole('button', { name: 'Pieces auswählen' }).click(); await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole'); await page.getByRole('button', { name: 'Filtern' }).click(); const consolePiece = page @@ -883,7 +891,9 @@ test('preparation opens and updates the concrete scheduled usage', async ({ page } }); -test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { +test('desktop builder opens the piece library beside the wider run sheet on demand', async ({ + page +}, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/fundus'); await setTheme(page, 'dark'); @@ -891,13 +901,15 @@ test('desktop builder keeps the fundus beside the wider run sheet', async ({ pag await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible(); - await expect(page.locator('.source-pane')).toBeVisible(); + await expect(page.locator('.source-pane')).toBeHidden(); await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeHidden(); await expect( page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' }) ).toBeVisible(); + await page.getByRole('button', { name: 'Pieces auswählen' }).click(); + await expect(page.locator('.source-pane')).toBeVisible(); const sourceBox = await page.locator('.source-pane').boundingBox(); const runBox = await page.locator('.run-pane').boundingBox(); expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0); @@ -984,6 +996,7 @@ test('a show can be planned from a template with independent usages and reserve' await sectionTransfer.dispose(); await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0); + await page.getByRole('button', { name: 'Pieces auswählen' }).click(); await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole'); await page.getByRole('button', { name: 'Filtern' }).click(); const source = page