Release 0.8.1
This commit is contained in:
1 parent
07f2c4607b
commit
abc31d920f
17 files changed
+908
-134
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.17 <25"
|
||||
|
||||
+442
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+111
-65
@@ -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<string, unknown>[]
|
||||
)
|
||||
.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<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 = (
|
||||
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<string, unknown>[]
|
||||
)
|
||||
.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<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 };
|
||||
}
|
||||
@@ -873,6 +873,10 @@
|
||||
>
|
||||
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
|
||||
<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>
|
||||
{#if showTimes}<strong class="live-piece-time">
|
||||
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
|
||||
|
||||
@@ -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</button
|
||||
onclick={() => {
|
||||
mobilePane = 'fundus';
|
||||
pieceLibraryOpen = true;
|
||||
}}>Pieces</button
|
||||
>
|
||||
<button
|
||||
class:active={mobilePane === 'plan'}
|
||||
@@ -418,9 +422,10 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="builder-workspace">
|
||||
<div class:library-open={pieceLibraryOpen} class="builder-workspace planning-workspace">
|
||||
<section
|
||||
class="builder-pane source-pane"
|
||||
class:library-open={pieceLibraryOpen}
|
||||
class:drop-ready={dropTarget === 'source'}
|
||||
class:mobile-hidden={mobilePane !== 'fundus'}
|
||||
aria-labelledby="source-title"
|
||||
@@ -439,7 +444,17 @@
|
||||
<p class="eyebrow">Quelle</p>
|
||||
<h2 id="source-title">Planbare Pieces</h2>
|
||||
</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>
|
||||
{#if dropTarget === 'source'}<div class="drag-remove-target">
|
||||
<strong>Aus Stream entfernen</strong><span>Das Piece-Original bleibt erhalten.</span>
|
||||
@@ -593,7 +608,18 @@
|
||||
<p class="eyebrow">Streamplan</p>
|
||||
<h2 id="run-title">Ablauf</h2>
|
||||
</div>
|
||||
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
|
||||
<div class="pane-heading-actions">
|
||||
<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>
|
||||
<div class="run-list">
|
||||
{#each data.show.sections as section, index (section.id)}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
};
|
||||
};
|
||||
+157
-56
@@ -1,8 +1,10 @@
|
||||
<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',
|
||||
@@ -13,18 +15,52 @@
|
||||
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 narrow-page search-page">
|
||||
<header class="page-heading">
|
||||
<p class="eyebrow">Arbeitsbereich</p>
|
||||
<h1>Suche</h1>
|
||||
<p>Durchsuche Pieces und Streams einschließlich abgeschlossener und archivierter Inhalte.</p>
|
||||
<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" />
|
||||
@@ -39,57 +75,122 @@
|
||||
<button class="primary-button" type="submit">Suchen</button>
|
||||
</form>
|
||||
|
||||
{#if data.query.length === 1}
|
||||
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
|
||||
{:else if data.query}
|
||||
<div class="search-result-columns">
|
||||
<section aria-labelledby="piece-results">
|
||||
<header class="result-group-heading">
|
||||
<h2 id="piece-results">Pieces</h2>
|
||||
<span>{data.results.pieces.length}</span>
|
||||
</header>
|
||||
{#if data.results.pieces.length}
|
||||
<div class="search-result-list">
|
||||
{#each data.results.pieces 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>
|
||||
{:else}<p class="empty-state">Keine passenden Pieces.</p>{/if}
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<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}
|
||||
<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>
|
||||
{:else}<p class="empty-state">Keine passenden Streams.</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{#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>
|
||||
+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.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
|
||||
|
||||
Reference in new issue
Block a user