From d5b1c58ba2036fbb33dd47dfc76ee7a0454e567d Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Mon, 26 Oct 2026 21:20:55 +0100 Subject: [PATCH] Release 0.7.2 --- CloudronManifest.json | 2 +- docs/ACCEPTANCE.md | 17 + docs/ARCHITECTURE.md | 16 + docs/DECISIONS.md | 14 + docs/RELEASE_NOTES.md | 26 + docs/STATUS.md | 25 + package-lock.json | 4 +- package.json | 2 +- src/app.css | 463 +++++++++++++++++- src/lib/components/FeedEditorDialog.svelte | 108 ++++ src/lib/components/RssSelectionDialog.svelte | 219 +++++++++ .../components/TechnicalAccessDialog.svelte | 128 +++++ src/lib/server/live.spec.ts | 6 +- src/lib/server/live.ts | 8 + src/lib/server/technical-access.spec.ts | 8 +- src/lib/server/technical-access.ts | 25 + src/routes/eingang/+page.server.ts | 20 +- src/routes/eingang/+page.svelte | 130 ++--- src/routes/live/+page.server.ts | 2 + src/routes/live/+page.svelte | 317 +++++++----- src/routes/verwaltung/+page.server.ts | 18 +- src/routes/verwaltung/+page.svelte | 308 ++++-------- tests/ui.e2e.ts | 219 ++++++++- 23 files changed, 1630 insertions(+), 455 deletions(-) create mode 100644 src/lib/components/FeedEditorDialog.svelte create mode 100644 src/lib/components/RssSelectionDialog.svelte create mode 100644 src/lib/components/TechnicalAccessDialog.svelte diff --git a/CloudronManifest.json b/CloudronManifest.json index f9b8ac6..9498423 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.7.1", + "version": "0.7.2", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 3a5f860..a81bd5c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,5 +1,22 @@ # Abnahme +## Wartungsrelease 0.7.2 + +| Bereich | Nachweis | Stand | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| RSS-Auswahl im Eingang (#16) | Chromium wählt zwei Beiträge über Quellenfilter und zwei Seiten, öffnet den Originallink ohne Umschalten und importiert nur die Auswahl | erfüllt am 26.10.2026 | +| Live-Menü nach Nutzerabsicht (#18) | Chromium prüft laufenden, pausierten und beendeten Zustand, direkte Timersteuerung, eingeklappte Ausgabe und erreichbare Navigation | erfüllt am 26.10.2026 | +| RSS-/API-Verwaltung im Katalogstil (#21) | Chromium legt einen Feed an und bearbeitet ihn; API-Zugang, einmaliges Tokenkopieren und Berechtigungsänderung geprüft | erfüllt am 26.10.2026 | +| Responsive Gestaltung und Farbschemata | RSS-Auswahl und Feed-Dialog mobil/dunkel, Verwaltung desktop/hell, Live-Menü mobil und bei 670 × 450 Pixeln geprüft | erfüllt am 26.10.2026 | +| Erhalt von Daten, Rechten und Sicherheitslogik | 13 gezielte Feed-/Live-/Zugangstests sowie vollständiger Verhaltenstestlauf; keine Schemaänderung | erfüllt am 26.10.2026 | + +- `npm test`: 60 von 60 Verhaltenstests erfolgreich. +- `npm run lint`: erfolgreich. +- `npm run check`: 0 Fehler und 0 Warnungen. +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`. +- `npm run test:e2e`: 20 von 20 Chromium-Abläufen erfolgreich. +- Andere Browser und echte Mobilgeräte: offen. + ## Wartungsrelease 0.7.1 | Bereich | Nachweis | Stand | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 918cf62..5d7bbfb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -465,3 +465,19 @@ Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und alle bisherigen Gastsitzungen werden in derselben Transaktion ungültig. Der neue Klartexttoken wird weiterhin nur in der Action-Antwort ausgegeben. + +## Bedienkorrekturen 0.7.2 + +Der RSS-Auswahldialog erhält weiterhin ausschließlich `new`-Einträge aus +`listFeedEntries`. Quellenfilter, Seiteneinteilung und noch nicht bestätigte +Auswahl sind flüchtiger Browserzustand. Erst das Formular im erreichbaren +Dialogfuß übergibt die gewählten IDs an die unveränderte transaktionale +Übernahme; dadurch bleiben persistenter Dublettenschutz, Herkunft und +Standardkategorien die einzige fachliche Wahrheit. + +Feed- und Zugangsdialoge verwenden dieselben globalen Management-Dialog- und +Katalogstile wie Werkzeuge. Das Bearbeiten eines technischen Zugangs ändert nur +Name und Umfang des bestehenden gehashten Tokens und wird auditiert. Die +Live-Route projiziert zusätzlich nur, ob der feste Ausgabekanal getrennt, mit +dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und +Ausgabedetails führt selbst keine Mutation aus. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e6b9c8c..5682335 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -518,3 +518,17 @@ Duplikation und ausdrückliches Aktualisieren kopieren den Zustand, während der Usage-Editor ihn danach unabhängig ändert. `piece_usages.status` bleibt davon getrennt und beschreibt weiterhin ausschließlich den Livezustand `open`, `current`, `done` oder `skipped`. + +## D-033: Auswahlzustand flüchtig, Verwaltungslogik gemeinsam + +Quellenfilter, Pagination und Mehrfachauswahl des RSS-Dialogs bleiben lokaler +UI-Zustand. Persistiert wird erst durch die bestehende transaktionale +Importaktion, damit Blättern oder Öffnen eines externen Links niemals +redaktionelle Inhalte erzeugt. Die Verwaltung ruft weiterhin dieselben Feed- +und Zugangsdienste auf; neue Dialoge ersetzen nur die dauerhaft ausgebreiteten +Formulare. Ein technischer Zugang darf Name und Umfang ändern, behält dabei aber +seinen ausschließlich gehasht gespeicherten Token und erzeugt ein Auditereignis. + +Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals. +Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen +verändern öffentliche Ausgabe. Navigation zur Planung bleibt davon getrennt. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 6fc2a9f..a49fb97 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,31 @@ # Release Notes +## 0.7.2 - RSS-Auswahl, Live-Menü und Schnittstellenverwaltung + +Veröffentlicht am 26. Oktober 2026 + +- Der Eingang zeigt neue RSS-Beiträge in einer kompakten Katalogfläche. Ein + großzügiger, mobil vollflächiger Dialog filtert mehrere Quellen vor der + Seiteneinteilung, zeigt 20 Beiträge je Seite und behält die barrierefrei + erkennbare Auswahl über Quellen- und Seitenwechsel. Erst die ausdrückliche + Übernahme erzeugt Eingangspieces; Herkunft, Kategorien, Verwerfen und + Dublettenschutz bleiben erhalten (#16). +- Das Live-Menü folgt der Nutzerabsicht: Sendungssteuerung, kompakte Ansicht, + Lesefreigaben, standardmäßig eingeklappte OBS-Ausgabe und getrennte Navigation + sind klar geordnet. Pause/Fortsetzen sowie Abschnitts- und Gesamtreset liegen + direkt bei den Timern. Laufend, pausiert und beendet werden eindeutig + unterschieden; Navigation allein verändert weder Lauf noch Ausgabe (#18). +- RSS-Feeds und technische API-Zugänge verwenden Panel, Typografie, + Eintragskarten, Classic-Regular-Icons und Dialograhmen des Werkzeugkatalogs. + Feeds und Zugänge lassen sich in Dialogen anlegen und bearbeiten; Abruf, + Löschen beziehungsweise Widerruf bleiben kompakte Iconaktionen. Neue + API-Tokens erscheinen unmittelbar einmalig und kopierbar, während weiterhin + nur ihr Hash gespeichert wird (#21). +- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.2`. Es ist keine + Datenbankmigration erforderlich. 60 Verhaltenstests und 20 Chromium-Abläufe + sind erfolgreich; echte Browser-/Geräteprüfungen außerhalb von Chromium + bleiben offen. + ## 0.7.1 - Bedienung und Live-Korrekturen Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026 diff --git a/docs/STATUS.md b/docs/STATUS.md index 15c5d96..21faa80 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,6 +8,31 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie, Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind dokumentiert. +## Bedienkorrekturen 0.7.2 + +- Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl + öffnet einen responsiven Dialog mit kombinierbaren Quellenfiltern, 20 Beiträgen + pro Seite, tastaturbedienbaren Auswahlkarten und einer festen bewussten + Übernahme. Auswahl bleibt beim Filtern und Blättern erhalten; importierte oder + verworfene Beiträge verschwinden über den bestehenden persistenten Status. +- Das Live-Menü trennt Sendungssteuerung, Ansicht, Lesefreigaben, + OBS-Ausgabekanal und Navigation. Pause/Fortsetzen und beide Timerresets liegen + bei den Zeitflächen. Der Menüstatus unterscheidet Entwurf, Bereit, Läuft, + Pausiert, Lauf beendet und historischen Abschluss; der OBS-Bereich bleibt bis + zur bewussten Aktion eingeklappt. +- Feedquellen und technische Zugänge verwenden dieselben Katalogkarten und + Dialoggrundlagen wie Werkzeuge. Beide lassen sich anlegen und bearbeiten; + Feeds behalten Abruf/Löschen, Zugänge ihren sofortigen Widerruf. Ein neuer + API-Token wird im Dialog einmalig angezeigt und kann direkt kopiert werden. + +Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und +20 von 20 Chromium-Abläufe. Der neue Ablauf prüft RSS-Auswahl über mehrere +Quellen und Seiten, Übernahme ohne erneutes Angebot, Feed- und Zugangsanlage +samt Bearbeitung und Tokenkopie. Das Live-Menü wurde laufend, pausiert und +beendet sowie bei kleinem Desktopfenster und mobil geprüft. Die betroffenen +Flächen wurden in hellem und dunklem Farbschema kontrolliert. Andere Browser und +echte Mobilgeräte bleiben offen. + ## Fehlerkorrekturen 0.7.1 - Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen diff --git a/package-lock.json b/package-lock.json index ddf9a4e..6d5efd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index bb4c8c9..0a93654 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.7.1", + "version": "0.7.2", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/src/app.css b/src/app.css index 9c51c7c..7141345 100644 --- a/src/app.css +++ b/src/app.css @@ -1575,16 +1575,58 @@ textarea:focus { text-align: center; } -.section-timer-control { +.section-timer-control, +.total-timer-control { display: flex; align-items: stretch; gap: 0.3rem; } -.section-timer-control form { +.section-timer-control > form, +.total-timer-control > form { display: flex; } +.timer-reset-menu { + position: relative; +} + +.timer-reset-menu > summary { + display: grid; + width: 1.65rem; + height: 100%; + min-height: 2rem; + place-items: center; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--panel-soft); + color: var(--text-muted); + cursor: pointer; + list-style: none; +} + +.timer-reset-menu > summary::-webkit-details-marker { + display: none; +} + +.timer-reset-menu > form { + position: absolute; + z-index: 220; + top: calc(100% + 0.35rem); + right: 0; + width: max-content; + padding: 0.4rem; + border: 1px solid var(--line-strong); + border-radius: 0.65rem; + background: var(--glass-strong); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.timer-reset-menu > form button { + font-size: 0.65rem; +} + .live-timer-toggle { display: grid; width: 2.15rem; @@ -1797,6 +1839,118 @@ textarea:focus { text-transform: uppercase; } +.live-menu-show-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.7rem; +} + +.live-menu-show-heading > span:first-child { + min-width: 0; +} + +.live-show-state { + flex: 0 0 auto; + padding: 0.25rem 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, var(--success) 14%, var(--panel)); + color: var(--success); + font-size: 0.58rem; + font-weight: 780; +} + +.live-show-state.paused { + background: color-mix(in srgb, var(--warning) 14%, var(--panel)); + color: var(--warning); +} + +.live-menu-hint { + margin: 0; + color: var(--text-faint); + font-size: 0.65rem; +} + +.live-view-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.live-view-controls .text-button, +.live-view-controls .theme-toggle { + min-height: 2.35rem; + font-size: 0.65rem; +} + +.live-output-settings > summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.45rem; + cursor: pointer; + list-style: none; +} + +.live-output-settings > summary::-webkit-details-marker { + display: none; +} + +.live-output-settings > summary > span { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.7rem; + font-weight: 750; +} + +.live-output-settings > summary > small { + color: var(--text-faint); + font-size: 0.58rem; +} + +.live-output-settings > summary > .app-icon { + transition: transform 140ms ease; +} + +.live-output-settings[open] > summary > .app-icon { + transform: rotate(90deg); +} + +.live-output-settings-body { + display: grid; + gap: 0.4rem; + margin-top: 0.55rem; + padding-top: 0.55rem; + border-top: 1px solid var(--line); +} + +.live-output-settings-body p { + margin: 0 0 0.15rem; + color: var(--text-faint); + font-size: 0.62rem; + line-height: 1.4; +} + +.live-menu-navigation { + display: grid; + gap: 0.2rem; + padding: 0.7rem 0.6rem 0.55rem; + border-top: 1px solid var(--line); +} + +.live-menu-navigation a { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.live-menu-navigation small { + color: var(--text-faint); + font-size: 0.58rem; +} + .live-menu-popover a { font-size: 0.72rem; font-weight: 700; @@ -2851,6 +3005,23 @@ textarea:focus { } @media (max-width: 52rem) { + .rss-entry-list { + grid-template-columns: 1fr; + } + + .rss-selection-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .rss-selection-actions button { + width: 100%; + } + + .catalog-setting-row { + grid-template-columns: minmax(0, 1fr) auto; + } + .heinzlive-output { padding: 1.2rem; } @@ -7393,6 +7564,7 @@ body:has(.heinzlive-output.empty), } .management-dialog input:not([type='checkbox'], [type='hidden']), +.management-dialog select, .management-dialog textarea { width: 100%; padding: 0.62rem 0.68rem; @@ -7524,6 +7696,293 @@ body:has(.heinzlive-output.empty), border-top: 1px solid var(--line); } +.catalog-setting-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--panel-soft) 82%, transparent); + overflow: hidden; +} + +.catalog-setting-row.inactive { + opacity: 0.68; +} + +.catalog-entry-main { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + min-width: 0; + padding: 0.65rem; + border: 0; + background: transparent; + color: var(--text); + text-align: left; +} + +button.catalog-entry-main { + cursor: pointer; +} + +button.catalog-entry-main:hover { + background: color-mix(in srgb, var(--gold) 7%, var(--panel-soft)); +} + +.catalog-entry-main > .app-icon { + color: var(--text-faint); +} + +.catalog-entry-actions { + display: flex; + align-items: center; + padding: 0.35rem; + border-left: 1px solid var(--line); +} + +.catalog-entry-actions form { + display: flex; +} + +.catalog-entry-actions button { + display: grid; + width: 2.15rem; + height: 2.15rem; + place-items: center; + padding: 0; + border: 0; + border-radius: 0.55rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.catalog-entry-actions button:hover, +.catalog-entry-actions button:focus-visible { + background: var(--panel); + color: var(--text); +} + +.rss-selection-dialog { + width: min(66rem, calc(100vw - 2rem)); +} + +.rss-selection-form { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; + overflow: hidden; +} + +.rss-selection-content { + display: grid; + min-height: 0; + align-content: start; + gap: 0.85rem; + padding: 1rem 1.1rem; + overflow-y: auto; +} + +.rss-source-filters { + display: flex; + gap: 0.4rem; + padding-bottom: 0.25rem; + overflow-x: auto; + scrollbar-width: thin; +} + +.rss-source-filters button { + flex: 0 0 auto; + padding: 0.48rem 0.72rem; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-soft); + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 0.68rem; + font-weight: 720; +} + +.rss-source-filters button.active { + border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); + background: color-mix(in srgb, var(--accent) 14%, var(--panel)); + color: var(--accent-text); +} + +.rss-result-summary, +.rss-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.rss-result-summary { + color: var(--text-faint); + font-size: 0.68rem; +} + +.rss-entry-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; +} + +.rss-entry { + display: grid; + position: relative; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 0.7rem; + min-width: 0; + padding: 0.8rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--panel-soft) 90%, transparent); + cursor: pointer; +} + +.rss-entry:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); +} + +.rss-entry.selected { + border-color: color-mix(in srgb, var(--accent) 72%, var(--line)); + background: color-mix(in srgb, var(--accent) 15%, var(--panel)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); +} + +[data-theme='light'] .rss-entry.selected { + background: color-mix(in srgb, var(--accent) 20%, #fff); +} + +.rss-entry:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.rss-entry-state { + display: grid; + width: 1.8rem; + height: 1.8rem; + place-items: center; + border: 1px solid var(--line); + border-radius: 50%; + background: var(--panel); + color: var(--text-faint); +} + +.rss-entry.selected .rss-entry-state { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.rss-entry-copy, +.rss-entry-copy > * { + min-width: 0; +} + +.rss-entry-copy { + display: grid; + gap: 0.25rem; +} + +.rss-entry-copy small { + color: var(--text-faint); + font-size: 0.62rem; +} + +.rss-entry-copy strong { + font-size: 0.78rem; + overflow-wrap: anywhere; +} + +.rss-entry-copy p { + display: -webkit-box; + margin: 0; + color: var(--text-muted); + font-size: 0.68rem; + line-height: 1.45; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.rss-entry-copy a { + width: fit-content; + font-size: 0.65rem; + font-weight: 720; +} + +.rss-selected-label { + position: absolute; + top: 0.55rem; + right: 0.6rem; + padding: 0.2rem 0.42rem; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 0.55rem; + font-weight: 780; +} + +.rss-entry.selected .rss-entry-copy small { + padding-right: 4.8rem; +} + +.rss-pagination { + justify-content: center; + font-size: 0.68rem; +} + +.rss-category-selection { + display: grid; + gap: 0.25rem; + padding-top: 0.8rem; + border-top: 1px solid var(--line); +} + +.rss-category-selection > small { + color: var(--text-faint); + font-size: 0.62rem; +} + +.rss-selection-actions { + display: flex; + justify-content: space-between; + gap: 0.7rem; +} + +.technical-token-result { + display: grid; + align-content: start; + gap: 0.8rem; + padding: 1rem 1.1rem; + overflow-y: auto; +} + +.technical-token-result code { + padding: 0.8rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + background: var(--panel-soft); + overflow-wrap: anywhere; + user-select: all; +} + +.technical-token-result .primary-button { + width: fit-content; +} + +.dialog-form-message { + margin: 1rem 1.1rem 0; +} + @media (max-width: 52rem) { .choice-picker-popover, .icon-picker-popover { diff --git a/src/lib/components/FeedEditorDialog.svelte b/src/lib/components/FeedEditorDialog.svelte new file mode 100644 index 0000000..457c14b --- /dev/null +++ b/src/lib/components/FeedEditorDialog.svelte @@ -0,0 +1,108 @@ + + + event.target === dialog && close()} +> +
+
+ +
+

RSS-Quelle

+

{feed?.name || 'Feed hinzufügen'}

+
+
+ +
+
+ {#if feed}{/if} +
+ + + + +
+
+ +
+
+ + +
+
+
diff --git a/src/lib/components/RssSelectionDialog.svelte b/src/lib/components/RssSelectionDialog.svelte new file mode 100644 index 0000000..4180f93 --- /dev/null +++ b/src/lib/components/RssSelectionDialog.svelte @@ -0,0 +1,219 @@ + + + event.target === dialog && close()} +> +
+
+ +
+

RSS-Importeingang

+

RSS-Beiträge auswählen

+
+
+ +
+
+ {#each selectedIds as id (id)}{/each} +
+ {#if form?.intent === 'feedImport' && form.feedError}{/if} + + +
+ {filteredEntries.length} verfügbare Beiträge + Seite {page} von {pageCount} +
+
+ {#each visibleEntries as entry (entry.id)} +
toggleEntry(entry.id)} + onkeydown={(event) => handleEntryKeydown(event, entry.id)} + > + +
+ {entry.feedName}{entry.publishedAtUtc + ? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}` + : ''} + {entry.title} + {#if entry.summary}

{entry.summary}

{/if} + event.stopPropagation()} + onkeydown={(event) => event.stopPropagation()}>Original öffnen +
+ {#if selectedIds.includes(entry.id)}Ausgewählt{/if} +
+ {:else}

+ Für diese Quellenauswahl sind keine Beiträge verfügbar. +

{/each} +
+ {#if pageCount > 1}{/if} +
+ Kategorien für die Auswahl + Ohne Auswahl gilt die Standardkategorie der jeweiligen Quelle. + (categoryIds = ids)} + /> +
+
+
+ + +
+
+
diff --git a/src/lib/components/TechnicalAccessDialog.svelte b/src/lib/components/TechnicalAccessDialog.svelte new file mode 100644 index 0000000..ddc33ad --- /dev/null +++ b/src/lib/components/TechnicalAccessDialog.svelte @@ -0,0 +1,128 @@ + + + event.target === dialog && close()} +> +
+
+ +
+

Externe Assistenz

+

{access?.name || 'API-Zugang hinzufügen'}

+
+
+ +
+ {#if form?.technicalToken && !access} +
+
+ Token, einmalig sichtbar +

Jetzt kopieren und sicher hinterlegen. Sendewerk speichert weiterhin nur den Hash.

+
+ {form.technicalToken} + + {#if copyMessage}{copyMessage}{/if} +
+ {:else} +
+ {#if access}{/if} + {#if form?.intent === 'technicalAccess' && form.error}{/if} +
+ + +
+
+ + +
+
+ {/if} +
diff --git a/src/lib/server/live.spec.ts b/src/lib/server/live.spec.ts index 8f28a0f..3a81cff 100644 --- a/src/lib/server/live.spec.ts +++ b/src/lib/server/live.spec.ts @@ -7,8 +7,9 @@ import { closeDatabase, getDatabase } from './database.js'; import { completeCurrent, clearPresentationChannel, - getPresentationChannelView, getLiveView, + getPresentationChannelStatus, + getPresentationChannelView, getPresentationMedia, getPresentationView, goBack, @@ -159,7 +160,9 @@ describe('live mode', () => { }); it('keeps one stable output channel until its key is explicitly rotated', () => { + expect(getPresentationChannelStatus('show-october')).toBe('disconnected'); const key = issuePresentationAccess('show-october'); + expect(getPresentationChannelStatus('show-october')).toBe('connected'); expect(issuePresentationAccess('show-october')).toBe(key); expect(presentationChannelAccess('default', key)).toBe(true); expect(getPresentationChannelView('default')).toEqual({ @@ -175,6 +178,7 @@ describe('live mode', () => { presentUsage('show-october', 'usage-console', 'present-channel', 1); expect(getPresentationChannelView('default')?.content).not.toBeNull(); clearPresentationChannel(); + expect(getPresentationChannelStatus('show-october')).toBe('disconnected'); expect(getPresentationChannelView('default')?.content).toBeNull(); issuePresentationAccess('show-october'); expect(getPresentationChannelView('default')?.content).toBeNull(); diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts index 5599be1..2327b44 100644 --- a/src/lib/server/live.ts +++ b/src/lib/server/live.ts @@ -1158,6 +1158,14 @@ export function presentationChannelAccess(channelId: string, accessKey: string) ); } +export function getPresentationChannelStatus(showId: string) { + const channel = getDatabase() + .prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'") + .get() as { show_id: string | null } | undefined; + if (!channel?.show_id) return 'disconnected' as const; + return channel.show_id === showId ? ('connected' as const) : ('other_show' as const); +} + function presentationShowId(database: Database.Database, token: string) { if (!token || token.length > 200) return null; const channel = database diff --git a/src/lib/server/technical-access.spec.ts b/src/lib/server/technical-access.spec.ts index 9d41484..f53d26a 100644 --- a/src/lib/server/technical-access.spec.ts +++ b/src/lib/server/technical-access.spec.ts @@ -9,7 +9,8 @@ import { authenticateTechnical, createTechnicalAccess, idempotentResponse, - revokeTechnicalAccess + revokeTechnicalAccess, + updateTechnicalAccess } from './technical-access.js'; let directory: string; @@ -64,6 +65,11 @@ describe('technical API access', () => { .prepare('SELECT token_hash FROM technical_accesses WHERE id = ?') .get(created.id) ).not.toMatchObject({ token_hash: created.token }); + updateTechnicalAccess(actor, created.id, 'Leser bearbeitet', 'read_write'); + expect(authenticateTechnical(request(created.token), true)).toMatchObject({ + name: 'Leser bearbeitet', + permissions: 'read_write' + }); revokeTechnicalAccess(actor, created.id); expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED'); }); diff --git a/src/lib/server/technical-access.ts b/src/lib/server/technical-access.ts index ead4859..3431e27 100644 --- a/src/lib/server/technical-access.ts +++ b/src/lib/server/technical-access.ts @@ -48,6 +48,31 @@ export function createTechnicalAccess(actor: AuthUser, nameValue: string, permis return { id, token }; } +export function updateTechnicalAccess( + actor: AuthUser, + id: string, + nameValue: string, + permissions: string +) { + const name = nameValue.trim(); + if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions)) + throw new Error('INVALID_ACCESS'); + const result = getDatabase() + .prepare( + `UPDATE technical_accesses SET name = ?, permissions = ? + WHERE id = ? AND revoked_at_utc IS NULL` + ) + .run(name, permissions, id); + if (!result.changes) throw new Error('NOT_FOUND'); + auditTechnical( + { id: actor.id, name: actor.displayName, permissions: 'read_write' }, + 'technical_access.update', + 'technical_access', + id, + 'user' + ); +} + export function revokeTechnicalAccess(actor: AuthUser, id: string) { const result = getDatabase() .prepare( diff --git a/src/routes/eingang/+page.server.ts b/src/routes/eingang/+page.server.ts index 5caa969..f9df720 100644 --- a/src/routes/eingang/+page.server.ts +++ b/src/routes/eingang/+page.server.ts @@ -46,22 +46,34 @@ export const actions = { requireUser(locals.user); const data = await request.formData(); const entryIds = data.getAll('entryIds').map(String); - if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' }); + if (!entryIds.length) + return fail(400, { + intent: 'feedImport', + feedError: 'Mindestens einen Beitrag auswählen.' + }); try { const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String)); return { + intent: 'feedImport', feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.` }; } catch { - return fail(400, { feedError: 'Die Auswahl konnte nicht übernommen werden.' }); + return fail(400, { + intent: 'feedImport', + feedError: 'Die Auswahl konnte nicht übernommen werden.' + }); } }, discardFeeds: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); const entryIds = data.getAll('entryIds').map(String); - if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' }); + if (!entryIds.length) + return fail(400, { intent: 'feedDiscard', feedError: 'Mindestens einen Beitrag auswählen.' }); const count = discardFeedEntries(entryIds); - return { feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` }; + return { + intent: 'feedDiscard', + feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` + }; } } satisfies Actions; diff --git a/src/routes/eingang/+page.svelte b/src/routes/eingang/+page.svelte index 55ef6a8..871511b 100644 --- a/src/routes/eingang/+page.svelte +++ b/src/routes/eingang/+page.svelte @@ -1,9 +1,10 @@ Eingang | Sendewerk @@ -44,65 +45,39 @@ -
+

RSS-Importeingang

-

Beiträge sichten

+

RSS-Beiträge

- {data.feedEntries.length} neu +
-

Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.

+

+ {data.feedEntries.length} + {data.feedEntries.length === 1 ? 'Beitrag ist' : 'Beiträge sind'} + verfügbar. Erst deine ausdrückliche Auswahl übernimmt sie in den redaktionellen Eingang. +

{#if form?.feedError}{/if} {#if form?.feedSuccess}
{form.feedSuccess}
{/if} - {#if data.feedEntries.length} -
-
- Kategorien für die Auswahl (Feed-Standard gilt ohne Auswahl) - {#each data.taxonomy.categories as category (category.id)} - - {/each} -
-
- {#each data.feedEntries as entry (entry.id)} - - {/each} -
-
- - -
-
- {:else} -

- Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen. -

- {/if} +
+ {#if data.pieces.length}

{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}

@@ -143,57 +118,16 @@ diff --git a/src/routes/live/+page.server.ts b/src/routes/live/+page.server.ts index fb1e8cb..f7c3657 100644 --- a/src/routes/live/+page.server.ts +++ b/src/routes/live/+page.server.ts @@ -8,6 +8,7 @@ import { clearPresentationChannel, endLive, getLiveView, + getPresentationChannelStatus, goBack, issuePresentationAccess, moveOpenUsage, @@ -87,6 +88,7 @@ export const load: PageServerLoad = ({ locals, url }) => { const live = getLiveView(showId); return { live, + outputStatus: getPresentationChannelStatus(showId), canManageShares: actor.role === 'barkeeper', shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [], commandId: randomUUID(), diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index d4289c4..f2c5239 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -300,9 +300,10 @@ menuOpen = true; expandedContext = null; await tick(); - liveMenuPopover - ?.querySelector('button:not(:disabled), a[href]') - ?.focus({ preventScroll: true }); + ( + liveMenuPopover?.querySelector('[data-menu-initial-focus]') ?? + liveMenuPopover?.querySelector('button:not(:disabled), a[href]') + )?.focus({ preventScroll: true }); } async function closeLiveMenu(restoreFocus = true) { @@ -361,6 +362,15 @@ return 'Kommend'; } + function showStateLabel() { + if (data.live.show.status === 'completed') return 'Historisch abgeschlossen'; + if (!data.live.startedAtUtc) + return data.live.show.status === 'ready' ? 'Bereit zum Start' : 'Entwurf'; + if (data.live.endedAtUtc) return 'Lauf beendet'; + if (data.live.isPaused) return 'Pausiert'; + return 'Läuft'; + } + function liveAction(name: string) { return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`; } @@ -481,28 +491,46 @@ {/if} - + {formatCountdown( + timerDisplay === 'elapsed' ? totalElapsedSeconds : remainingSeconds + )} + {#if timerDisplay === 'remaining' && data.live.remainingEstimate.unplannedSectionCount} + + {data.live.remainingEstimate.unplannedSectionCount} ungeplant + {/if} + + {#if data.live.startedAtUtc && !data.live.endedAtUtc}
+ +
{ + if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault(); + }} + > + + + + +
+
{/if} +
{/if} + {#if data.live.startedAtUtc && !data.live.endedAtUtc && data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}
+ +
{ + if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault(); + }} + > + + + + +
+
{/if}
- + Sendung steuern +
+ + Sendung + {data.live.show.title} + + {showStateLabel()}
- - -
-
- Ausgabekanal - {#if data.canManageShares}{/if} -
- - -
-
- - -
-
- -
-
-
- Sendungssteuerung - Status: {data.live.show.status} {#if !data.live.startedAtUtc} +
+ + + + +
-
- - - - -
{:else if data.live.endedAtUtc} {#if data.live.show.status === 'completed'} -

Diese Sendung ist historisch abgeschlossen und unveränderlich.

- Historische Sendung öffnen +

Diese Sendung ist historisch abgeschlossen.

{:else}
Beendeten Lauf wiederaufnehmen
{/if} {:else} - - - - - -
- {#if data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}
{ - if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault(); - }} > - +
{/if} -
{ - if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault(); - }} - > - - - - -
+ {#if data.live.isPaused}

+ Fortsetzen ist direkt bei der Abschnittszeit erreichbar. +

{/if}
{/if} - {#if needsResume && !data.live.endedAtUtc}
- - - - -
{/if} - Liveansicht verlassen · Zur Planung
+
+ Ansicht +
+
+ + +
+ + +
+
+ {#if data.canManageShares}
+ Teilen + +
{/if} +
+
+ + Ausgabe einrichten + {data.outputStatus === 'connected' + ? 'Mit dieser Sendung verbunden' + : data.outputStatus === 'other_show' + ? 'Mit einer anderen Sendung verbunden' + : 'Nicht verbunden'} + + +
+

Der OBS-Ausgabekanal ändert sich erst durch eine der folgenden Aktionen.

+
+ + +
+ {#if data.outputStatus === 'connected'} +
{ + if ( + !confirm( + 'Schlüssel erneuern? Bereits eingerichtete OBS-Quellen verlieren den Zugriff.' + ) + ) + event.preventDefault(); + }} + > + + +
+
{ + if ( + !confirm( + 'Ausgabekanal trennen? Verbundene Browserquellen zeigen anschließend keine Sendung.' + ) + ) + event.preventDefault(); + }} + > + +
+ {/if} +
+
+
+ {/if}
diff --git a/src/routes/verwaltung/+page.server.ts b/src/routes/verwaltung/+page.server.ts index 7ab9b20..74ad9ac 100644 --- a/src/routes/verwaltung/+page.server.ts +++ b/src/routes/verwaltung/+page.server.ts @@ -22,7 +22,8 @@ import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#l import { createTechnicalAccess, listTechnicalAccesses, - revokeTechnicalAccess + revokeTechnicalAccess, + updateTechnicalAccess } from '#lib/server/technical-access.js'; import { inviteUser, @@ -343,6 +344,21 @@ export const actions = { return fail(400, { intent: 'technicalAccess', error: actionError(cause) }); } }, + updateTechnicalAccess: async ({ locals, request }) => { + const actor = requireBarkeeper(locals.user); + const data = await request.formData(); + try { + updateTechnicalAccess( + actor, + String(data.get('accessId') || ''), + String(data.get('name') || ''), + String(data.get('permissions') || '') + ); + return { intent: 'technicalAccess', success: 'Technischer Zugang aktualisiert.' }; + } catch (cause) { + return fail(400, { intent: 'technicalAccess', error: actionError(cause) }); + } + }, revokeTechnicalAccess: async ({ locals, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); diff --git a/src/routes/verwaltung/+page.svelte b/src/routes/verwaltung/+page.svelte index 3ffa374..1632ecb 100644 --- a/src/routes/verwaltung/+page.svelte +++ b/src/routes/verwaltung/+page.svelte @@ -1,12 +1,19 @@