Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abc31d920f |
No files matched your search
@@ -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.8.0",
|
"version": "0.8.1",
|
||||||
"healthCheckPath": "/healthz",
|
"healthCheckPath": "/healthz",
|
||||||
"httpPort": 3000,
|
"httpPort": 3000,
|
||||||
"addons": {
|
"addons": {
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ Zielressource erneut. MCP kann keine Inhalte schreiben.
|
|||||||
|
|
||||||
### In ChatGPT einrichten
|
### 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
|
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
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# Abnahme
|
# 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
|
## Redaktioneller Arbeitsbereich 0.8.0
|
||||||
|
|
||||||
| Bereich | Nachweis | Stand |
|
| Bereich | Nachweis | Stand |
|
||||||
|
|||||||
@@ -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
|
der mobilen Grenze öffnet dieselbe Komponente modal und nutzt den verfügbaren
|
||||||
Viewport.
|
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
|
## Laufzeit und Speicher
|
||||||
|
|
||||||
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
|
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
|
||||||
|
|||||||
@@ -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
|
bleiben dieselbe Komponente, Validierung und Form-Action erhalten, während der
|
||||||
Ablauf daneben bedienbar bleibt. Mobil verwendet dieselbe Komponente weiterhin
|
Ablauf daneben bedienbar bleibt. Mobil verwendet dieselbe Komponente weiterhin
|
||||||
einen modalen Vollbildkontext.
|
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.
|
||||||
@@ -1,5 +1,35 @@
|
|||||||
# Release Notes
|
# 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
|
## 0.8.0 - Redaktioneller Arbeitsbereich
|
||||||
|
|
||||||
Veröffentlicht am 27. Oktober 2026
|
Veröffentlicht am 27. Oktober 2026
|
||||||
|
|||||||
@@ -8,6 +8,34 @@ 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.
|
||||||
|
|
||||||
|
## 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
|
## Redaktioneller Arbeitsbereich 0.8.0
|
||||||
|
|
||||||
- Die sichtbare Anwendungssprache verwendet durchgehend Pieces und Streams; die
|
- Die sichtbare Anwendungssprache verwendet durchgehend Pieces und Streams; die
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.2",
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"@tiptap/core": "^3.32.0",
|
"@tiptap/core": "^3.32.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17 <25"
|
"node": ">=22.17 <25"
|
||||||
|
|||||||
+442
@@ -47,6 +47,418 @@
|
|||||||
--radius-small: 0.65rem;
|
--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 {
|
.completion-dialog {
|
||||||
width: min(38rem, calc(100% - 2rem));
|
width: min(38rem, calc(100% - 2rem));
|
||||||
max-height: calc(100dvh - 2rem);
|
max-height: calc(100dvh - 2rem);
|
||||||
@@ -9146,3 +9558,33 @@ button.catalog-entry-main:hover {
|
|||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,4 +48,18 @@ describe('global workspace search', () => {
|
|||||||
expect(results.shows.length).toBeGreaterThan(0);
|
expect(results.shows.length).toBeGreaterThan(0);
|
||||||
expect(results.shows[0].href).toMatch(/^\/sendungen\//);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -16,6 +16,18 @@ export type GlobalSearchResults = {
|
|||||||
shows: 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 };
|
type SearchField = { label: string; value: string };
|
||||||
|
|
||||||
function normalized(value: unknown): string {
|
function normalized(value: unknown): string {
|
||||||
@@ -42,24 +54,54 @@ function match(
|
|||||||
return null;
|
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);
|
const query = normalized(rawQuery).toLocaleLowerCase('de-DE').slice(0, 200);
|
||||||
if (query.length < 2) return { pieces: [], shows: [] };
|
if (query.length < 2) return { pieces: [], shows: [] };
|
||||||
|
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
const pieces = (
|
const pieces =
|
||||||
|
filters.type === 'shows'
|
||||||
|
? []
|
||||||
|
: (
|
||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text,
|
`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.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
|
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
|
WHERE p.deleted_at_utc IS NULL
|
||||||
GROUP BY p.id ORDER BY p.updated_at_utc DESC`
|
GROUP BY p.id ORDER BY p.updated_at_utc DESC`
|
||||||
)
|
)
|
||||||
.all() as Record<string, unknown>[]
|
.all() as Record<string, unknown>[]
|
||||||
)
|
)
|
||||||
.map((row): SearchResult | null => {
|
.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(
|
const found = match(
|
||||||
[
|
[
|
||||||
{ label: 'Titel', value: String(row.title) },
|
{ label: 'Titel', value: String(row.title) },
|
||||||
@@ -88,7 +130,10 @@ export function searchWorkspace(rawQuery: string): GlobalSearchResults {
|
|||||||
.filter((result): result is SearchResult => result !== null)
|
.filter((result): result is SearchResult => result !== null)
|
||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
|
|
||||||
const shows = (
|
const shows =
|
||||||
|
filters.type === 'pieces' || filters.topicId || filters.formatId
|
||||||
|
? []
|
||||||
|
: (
|
||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text,
|
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text,
|
||||||
@@ -105,6 +150,7 @@ export function searchWorkspace(rawQuery: string): GlobalSearchResults {
|
|||||||
.all() as Record<string, unknown>[]
|
.all() as Record<string, unknown>[]
|
||||||
)
|
)
|
||||||
.map((row): SearchResult | null => {
|
.map((row): SearchResult | null => {
|
||||||
|
if (filters.status && row.status !== filters.status) return null;
|
||||||
const found = match(
|
const found = match(
|
||||||
[
|
[
|
||||||
{ label: 'Titel', value: String(row.title) },
|
{ label: 'Titel', value: String(row.title) },
|
||||||
|
|||||||
@@ -873,6 +873,10 @@
|
|||||||
>
|
>
|
||||||
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
|
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
|
||||||
<div class="live-piece-heading">
|
<div class="live-piece-heading">
|
||||||
|
<div class="live-piece-context">
|
||||||
|
<span>Piece {viewedPosition || '–'} von {data.live.totalCount}</span>
|
||||||
|
{#if viewedSection}<span>{viewedSection.name}</span>{/if}
|
||||||
|
</div>
|
||||||
<h1 id="current-title">{viewedUsage.title}</h1>
|
<h1 id="current-title">{viewedUsage.title}</h1>
|
||||||
{#if showTimes}<strong class="live-piece-time">
|
{#if showTimes}<strong class="live-piece-time">
|
||||||
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
|
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
let mobilePane = $state<'fundus' | 'plan'>('plan');
|
let mobilePane = $state<'fundus' | 'plan'>('plan');
|
||||||
|
let pieceLibraryOpen = $state(false);
|
||||||
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 }
|
||||||
@@ -408,7 +409,10 @@
|
|||||||
class:active={mobilePane === 'fundus'}
|
class:active={mobilePane === 'fundus'}
|
||||||
aria-pressed={mobilePane === 'fundus'}
|
aria-pressed={mobilePane === 'fundus'}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (mobilePane = 'fundus')}>Pieces</button
|
onclick={() => {
|
||||||
|
mobilePane = 'fundus';
|
||||||
|
pieceLibraryOpen = true;
|
||||||
|
}}>Pieces</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class:active={mobilePane === 'plan'}
|
class:active={mobilePane === 'plan'}
|
||||||
@@ -418,9 +422,10 @@
|
|||||||
>
|
>
|
||||||
</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"
|
||||||
@@ -439,7 +444,17 @@
|
|||||||
<p class="eyebrow">Quelle</p>
|
<p class="eyebrow">Quelle</p>
|
||||||
<h2 id="source-title">Planbare Pieces</h2>
|
<h2 id="source-title">Planbare Pieces</h2>
|
||||||
</div>
|
</div>
|
||||||
<span>{visiblePieces.length}</span>
|
<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>
|
||||||
</header>
|
</header>
|
||||||
{#if dropTarget === 'source'}<div class="drag-remove-target">
|
{#if dropTarget === 'source'}<div class="drag-remove-target">
|
||||||
<strong>Aus Stream entfernen</strong><span>Das Piece-Original bleibt erhalten.</span>
|
<strong>Aus Stream entfernen</strong><span>Das Piece-Original bleibt erhalten.</span>
|
||||||
@@ -593,7 +608,18 @@
|
|||||||
<p class="eyebrow">Streamplan</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)}
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { requireUser } from '#lib/server/auth.js';
|
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 }) => {
|
export const load: PageServerLoad = ({ locals, url }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const query = (url.searchParams.get('q') ?? '').trim().slice(0, 200);
|
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<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
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
+142
-41
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
let filtersOpen = $state(false);
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
inbox: 'Eingang',
|
inbox: 'Eingang',
|
||||||
@@ -13,18 +15,52 @@
|
|||||||
live: 'Live',
|
live: 'Live',
|
||||||
completed: 'Abgeschlossen'
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Suche | Sendewerk</title></svelte:head>
|
<svelte:head><title>Suche | Sendewerk</title></svelte:head>
|
||||||
|
|
||||||
<section class="page-wrap narrow-page search-page">
|
<section class="page-wrap search-page">
|
||||||
<header class="page-heading">
|
<header class="search-page-heading">
|
||||||
|
<div>
|
||||||
<p class="eyebrow">Arbeitsbereich</p>
|
<p class="eyebrow">Arbeitsbereich</p>
|
||||||
<h1>Suche</h1>
|
<h1>Suche</h1>
|
||||||
<p>Durchsuche Pieces und Streams einschließlich abgeschlossener und archivierter Inhalte.</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<form class="search-page-form" method="GET" action={resolve('/suche')} role="search">
|
<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>
|
<label>
|
||||||
<span class="sr-only">Suchbegriff</span>
|
<span class="sr-only">Suchbegriff</span>
|
||||||
<AppIcon name="magnifying-glass" />
|
<AppIcon name="magnifying-glass" />
|
||||||
@@ -39,57 +75,122 @@
|
|||||||
<button class="primary-button" type="submit">Suchen</button>
|
<button class="primary-button" type="submit">Suchen</button>
|
||||||
</form>
|
</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}
|
{#if data.query.length === 1}
|
||||||
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
|
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
|
||||||
{:else if data.query}
|
{:else if data.query}
|
||||||
<div class="search-result-columns">
|
<header class="search-results-heading">
|
||||||
<section aria-labelledby="piece-results">
|
<div>
|
||||||
<header class="result-group-heading">
|
<p class="eyebrow">Treffer</p>
|
||||||
<h2 id="piece-results">Pieces</h2>
|
<h2>{totalCount} Ergebnisse</h2>
|
||||||
<span>{data.results.pieces.length}</span>
|
</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>
|
</header>
|
||||||
{#if data.results.pieces.length}
|
|
||||||
<div class="search-result-list">
|
<div class="search-result-list">
|
||||||
{#each data.results.pieces as result (result.id)}
|
{#each results as result (result.id)}
|
||||||
<!-- Search results contain only server-generated internal paths. -->
|
<!-- Search results contain only server-generated internal paths. -->
|
||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={result.href}>
|
<a href={result.href}
|
||||||
<span class="result-title"
|
><span class="result-title"
|
||||||
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
|
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
|
||||||
>{statusLabels[result.status] ?? result.status}</span
|
>{statusLabels[result.status] ?? result.status}</span
|
||||||
></span
|
></span
|
||||||
|
><small>{result.matchLabel}</small>
|
||||||
|
<p>{result.excerpt}</p></a
|
||||||
>
|
>
|
||||||
<small>{result.matchLabel}</small>
|
|
||||||
<p>{result.excerpt}</p>
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}<p class="empty-state">Keine passenden Pieces.</p>{/if}
|
</section>{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-labelledby="show-results">
|
|
||||||
<header class="result-group-heading">
|
|
||||||
<h2 id="show-results">Streams</h2>
|
|
||||||
<span>{data.results.shows.length}</span>
|
|
||||||
</header>
|
|
||||||
{#if data.results.shows.length}
|
|
||||||
<div class="search-result-list">
|
|
||||||
{#each data.results.shows as result (result.id)}
|
|
||||||
<!-- Search results contain only server-generated internal paths. -->
|
|
||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
|
||||||
<a href={result.href}>
|
|
||||||
<span class="result-title"
|
|
||||||
><strong>{result.title}</strong><span class={`show-state ${result.status}`}
|
|
||||||
>{statusLabels[result.status] ?? result.status}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
<small>{result.matchLabel}</small>
|
|
||||||
<p>{result.excerpt}</p>
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
{:else}<p class="empty-state">Keine passenden Streams.</p>{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
+15
-2
@@ -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.getByRole('heading', { name: 'Pieces', exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('Die kleine Konsole mit dem großen Dreh')).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.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.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 page.goto('/');
|
||||||
await expect(page.locator('.app-header')).toBeHidden();
|
await expect(page.locator('.app-header')).toBeHidden();
|
||||||
await expect(
|
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.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
|
||||||
await page.getByRole('radio', { name: /Standardablauf/ }).check();
|
await page.getByRole('radio', { name: /Standardablauf/ }).check();
|
||||||
await page.getByRole('button', { name: 'Stream anlegen' }).click();
|
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.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
|
||||||
await page.getByRole('button', { name: 'Filtern' }).click();
|
await page.getByRole('button', { name: 'Filtern' }).click();
|
||||||
const consolePiece = page
|
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.setViewportSize({ width: 1280, height: 900 });
|
||||||
await page.goto('/fundus');
|
await page.goto('/fundus');
|
||||||
await setTheme(page, 'dark');
|
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.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
|
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('.run-pane')).toBeVisible();
|
||||||
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
|
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
|
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
|
||||||
).toBeVisible();
|
).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 sourceBox = await page.locator('.source-pane').boundingBox();
|
||||||
const runBox = await page.locator('.run-pane').boundingBox();
|
const runBox = await page.locator('.run-pane').boundingBox();
|
||||||
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
|
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 sectionTransfer.dispose();
|
||||||
await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0);
|
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.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
|
||||||
await page.getByRole('button', { name: 'Filtern' }).click();
|
await page.getByRole('button', { name: 'Filtern' }).click();
|
||||||
const source = page
|
const source = page
|
||||||
|
|||||||
Reference in new issue
Block a user