Release 0.8.1

This commit is contained in:
hnzio committed 2026-10-27 19:22:31 +01:00
1 parent 07f2c4607b
commit abc31d920f
17 files changed
+908 -134

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.8.0", "version": "0.8.1",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+1 -1
View File
@@ -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
+19
View File
@@ -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 |
+12
View File
@@ -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.
+14
View File
@@ -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.
+30
View File
@@ -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
+28
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+14
View File
@@ -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([]);
});
}); });
+111 -65
View File
@@ -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,56 +54,89 @@ 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 =
database filters.type === 'shows'
.prepare( ? []
`SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text, : (
database
.prepare(
`SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text, p.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 => {
const found = match( if (filters.status && row.status !== filters.status) return null;
[ if (filters.formatId && row.format_id !== filters.formatId) return null;
{ label: 'Titel', value: String(row.title) }, if (
{ label: 'Ausgabetitel', value: String(row.output_title) }, filters.topicId &&
{ label: 'Zusammenfassung', value: String(row.summary) }, !String(row.topic_ids ?? '')
{ label: 'Anmoderation', value: String(row.intro) }, .split(',')
{ label: 'Recherche', value: String(row.research_text) }, .includes(filters.topicId)
{ label: 'Moderation', value: String(row.moderation_notes) }, )
{ label: 'Interaktion', value: String(row.interaction_text) }, return null;
{ label: 'Übergang', value: String(row.transition_text) }, const found = match(
{ label: 'Präsentation', value: String(row.presentation_text) }, [
{ label: 'Quellen', value: String(row.sources) } { label: 'Titel', value: String(row.title) },
], { label: 'Ausgabetitel', value: String(row.output_title) },
query { label: 'Zusammenfassung', value: String(row.summary) },
); { label: 'Anmoderation', value: String(row.intro) },
if (!found) return null; { label: 'Recherche', value: String(row.research_text) },
const status = row.status as PieceStatus; { label: 'Moderation', value: String(row.moderation_notes) },
return { { label: 'Interaktion', value: String(row.interaction_text) },
id: String(row.id), { label: 'Übergang', value: String(row.transition_text) },
title: String(row.title), { label: 'Präsentation', value: String(row.presentation_text) },
href: status === 'archived' ? `/archiv#piece-${row.id}` : `/fundus/${row.id}`, { label: 'Quellen', value: String(row.sources) }
status, ],
...found query
}; );
}) if (!found) return null;
.filter((result): result is SearchResult => result !== null) const status = row.status as PieceStatus;
.slice(0, 50); 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 = ( const shows =
database filters.type === 'pieces' || filters.topicId || filters.formatId
.prepare( ? []
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text, : (
database
.prepare(
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text,
group_concat(COALESCE(s.name, '') || ' ' || COALESCE(u.title_snapshot, '') || ' ' || group_concat(COALESCE(s.name, '') || ' ' || COALESCE(u.title_snapshot, '') || ' ' ||
COALESCE(u.output_title_snapshot, '') || ' ' || COALESCE(u.intro_snapshot, '') || ' ' || COALESCE(u.output_title_snapshot, '') || ' ' || COALESCE(u.intro_snapshot, '') || ' ' ||
COALESCE(u.research_snapshot, '') || ' ' || COALESCE(u.moderation_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 show_sections s ON s.show_id = sh.id
LEFT JOIN piece_usages u ON u.show_section_id = s.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` GROUP BY sh.id ORDER BY sh.starts_at_utc DESC, sh.created_at_utc DESC`
) )
.all() as Record<string, unknown>[] .all() as Record<string, unknown>[]
) )
.map((row): SearchResult | null => { .map((row): SearchResult | null => {
const found = match( 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: 'Titel', value: String(row.title) },
{ label: 'Ankündigung', value: String(row.announcement_text) }, { label: 'Twitch-Titel', value: String(row.twitch_title) },
{ label: 'Ablauf und Pieces', value: String(row.contents) } { label: 'Ankündigung', value: String(row.announcement_text) },
], { label: 'Ablauf und Pieces', value: String(row.contents) }
query ],
); query
if (!found) return null; );
return { if (!found) return null;
id: String(row.id), return {
title: String(row.title), id: String(row.id),
href: `/sendungen/${row.id}`, title: String(row.title),
status: row.status as ShowStatus, href: `/sendungen/${row.id}`,
...found status: row.status as ShowStatus,
}; ...found
}) };
.filter((result): result is SearchResult => result !== null) })
.slice(0, 50); .filter((result): result is SearchResult => result !== null)
.slice(0, 50);
return { pieces, shows }; return { pieces, shows };
} }
+4
View File
@@ -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)}
+30 -4
View File
@@ -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>
<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> </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)}
+27 -2
View File
@@ -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
}
};
}; };
+157 -56
View File
@@ -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">
<p class="eyebrow">Arbeitsbereich</p> <div>
<h1>Suche</h1> <p class="eyebrow">Arbeitsbereich</p>
<p>Durchsuche Pieces und Streams einschließlich abgeschlossener und archivierter Inhalte.</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> </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>
{#if data.query.length === 1} <div class="search-workspace">
<p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p> <aside class:open={filtersOpen} class="search-filter-panel" id="search-filters">
{:else if data.query} <header>
<div class="search-result-columns"> <div>
<section aria-labelledby="piece-results"> <p class="eyebrow">Eingrenzen</p>
<header class="result-group-heading"> <h2>Filter</h2>
<h2 id="piece-results">Pieces</h2> </div>
<span>{data.results.pieces.length}</span> <button
</header> class="dialog-close"
{#if data.results.pieces.length} type="button"
<div class="search-result-list"> aria-label="Filter schließen"
{#each data.results.pieces as result (result.id)} onclick={() => (filtersOpen = false)}>×</button
<!-- Search results contain only server-generated internal paths. --> >
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> </header>
<a href={result.href}> <nav aria-label="Ergebnisart">
<span class="result-title" <!-- Links use the resolved route and controlled filter parameters. -->
><strong>{result.title}</strong><span class={`show-state ${result.status}`} <!-- eslint-disable svelte/no-navigation-without-resolve -->
>{statusLabels[result.status] ?? result.status}</span <a class:active={data.filters.type === 'all'} href={typeHref('all')}
></span ><span>Alle Ergebnisse</span><strong>{allCount}</strong></a
> >
<small>{result.matchLabel}</small> <a class:active={data.filters.type === 'pieces'} href={typeHref('pieces')}
<p>{result.excerpt}</p> ><span>Pieces</span><strong>{data.counts.pieces}</strong></a
</a> >
{/each} <a class:active={data.filters.type === 'shows'} href={typeHref('shows')}
</div> ><span>Streams</span><strong>{data.counts.shows}</strong></a
{:else}<p class="empty-state">Keine passenden Pieces.</p>{/if} >
</section> <!-- 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"> <main class="search-results" aria-live="polite">
<header class="result-group-heading"> {#if data.query.length === 1}
<h2 id="show-results">Streams</h2> <p class="empty-state">Bitte mindestens zwei Zeichen eingeben.</p>
<span>{data.results.shows.length}</span> {:else if data.query}
</header> <header class="search-results-heading">
{#if data.results.shows.length} <div>
<div class="search-result-list"> <p class="eyebrow">Treffer</p>
{#each data.results.shows as result (result.id)} <h2>{totalCount} Ergebnisse</h2>
<!-- 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> </div>
{:else}<p class="empty-state">Keine passenden Streams.</p>{/if} {#if activeFilterCount}<span>{activeFilterCount} aktive Filter</span>{/if}
</section> </header>
</div> {#each [['pieces', 'Pieces'], ['shows', 'Streams']] as group (group[0])}
{/if} {@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> </section>
+15 -2
View File
@@ -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