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",
"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": {
+1 -1
View File
@@ -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
+19
View File
@@ -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 |
+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
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.
+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
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.
+30
View File
@@ -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
+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
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
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.8.0",
"version": "0.8.1",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+442
View File
@@ -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;
}
}
+14
View File
@@ -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
View File
@@ -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 };
}
+4
View File
@@ -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)}
+30 -4
View File
@@ -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)}
+27 -2
View File
@@ -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
View File
@@ -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
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.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