1 Commits
Author SHA1 Message Date
hnzio d5b1c58ba2 Release 0.7.2 2026-10-26 21:20:55 +01:00
23 changed files with 1630 additions and 455 deletions

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.7.1",
"version": "0.7.2",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+17
View File
@@ -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 |
+16
View File
@@ -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.
+14
View File
@@ -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.
+26
View File
@@ -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
+25
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.7.1",
"version": "0.7.2",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+461 -2
View File
@@ -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 {
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Feed = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
};
let { categories }: { categories: { id: string; name: string }[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let feed = $state<Feed | null>(null);
let name = $state('');
let url = $state('');
let refreshIntervalMinutes = $state(60);
let defaultCategoryId = $state('');
let isEnabled = $state(true);
export async function showModal(selectedFeed: Feed | null = null) {
feed = selectedFeed;
name = selectedFeed?.name ?? '';
url = selectedFeed?.url ?? '';
refreshIntervalMinutes = selectedFeed?.refreshIntervalMinutes ?? 60;
defaultCategoryId = selectedFeed?.defaultCategoryId ?? '';
isEnabled = selectedFeed?.isEnabled ?? true;
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog feed-editor-dialog"
aria-labelledby="feed-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
<div>
<p class="eyebrow">RSS-Quelle</p>
<h2 id="feed-dialog-title">{feed?.name || 'Feed hinzufügen'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Feeddialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form class="management-dialog-form" method="POST" action="?/saveFeed">
{#if feed}<input type="hidden" name="feedId" value={feed.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Feed-URL<input bind:value={url} name="url" type="url" required maxlength="2000" /></label
>
<label
>Intervall in Minuten<input
bind:value={refreshIntervalMinutes}
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
required
/></label
>
<label
>Standardkategorie<select bind:value={defaultCategoryId} name="defaultCategoryId"
><option value="">Keine</option>{#each categories as category (category.id)}<option
value={category.id}>{category.name}</option
>{/each}</select
></label
>
</div>
<div class="management-dialog-selections">
<label class="toggle-row"
><input type="checkbox" name="isEnabled" value="1" bind:checked={isEnabled} /><span
><strong>Automatischer Abruf</strong><small
>Der Feed wird im festgelegten Intervall sicher abgerufen.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{feed ? 'Änderungen speichern' : 'Feed anlegen'}</button
>
</footer>
</form>
</dialog>
@@ -0,0 +1,219 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
};
let {
entries,
categories,
form = null
}: {
entries: FeedEntry[];
categories: { id: string; name: string }[];
form?: { intent?: string; feedError?: string } | null;
} = $props();
let dialog: HTMLDialogElement;
let closeButton: HTMLButtonElement;
let selectedIds = $state<string[]>([]);
let selectedFeedIds = $state<string[]>([]);
let categoryIds = $state<string[]>([]);
let page = $state(1);
const pageSize = 20;
let feeds = $derived(
Array.from(new Map(entries.map((entry) => [entry.feedId, entry.feedName])).entries()).map(
([id, name]) => ({ id, name })
)
);
let filteredEntries = $derived(entries.filter((entry) => selectedFeedIds.includes(entry.feedId)));
let pageCount = $derived(Math.max(1, Math.ceil(filteredEntries.length / pageSize)));
let visibleEntries = $derived(filteredEntries.slice((page - 1) * pageSize, page * pageSize));
let allSourcesSelected = $derived(feeds.length > 0 && selectedFeedIds.length === feeds.length);
onMount(() => {
selectedFeedIds = feeds.map((feed) => feed.id);
if (form?.intent === 'feedImport' && form.feedError) void showModal();
});
export async function showModal() {
if (!selectedFeedIds.length) selectedFeedIds = feeds.map((feed) => feed.id);
if (!dialog.open) dialog.showModal();
await tick();
closeButton.focus();
}
function close() {
dialog.close();
}
function selectAllSources() {
selectedFeedIds = feeds.map((feed) => feed.id);
page = 1;
}
function toggleSource(id: string) {
selectedFeedIds = selectedFeedIds.includes(id)
? selectedFeedIds.filter((feedId) => feedId !== id)
: [...selectedFeedIds, id];
page = 1;
}
function toggleEntry(id: string) {
selectedIds = selectedIds.includes(id)
? selectedIds.filter((entryId) => entryId !== id)
: [...selectedIds, id];
}
function handleEntryKeydown(event: KeyboardEvent, id: string) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleEntry(id);
}
</script>
<dialog
bind:this={dialog}
class="management-dialog rss-selection-dialog"
aria-labelledby="rss-selection-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2 id="rss-selection-title">RSS-Beiträge auswählen</h2>
</div>
</div>
<button
bind:this={closeButton}
class="dialog-close"
type="button"
aria-label="RSS-Auswahl schließen"
onclick={close}><AppIcon name="xmark" /></button
>
</header>
<form class="rss-selection-form" method="POST" action="?/importFeeds">
{#each selectedIds as id (id)}<input type="hidden" name="entryIds" value={id} />{/each}
<div class="rss-selection-content">
{#if form?.intent === 'feedImport' && form.feedError}<div
class="form-message error"
role="alert"
>
{form.feedError}
</div>{/if}
<nav class="rss-source-filters" aria-label="RSS-Quellen filtern">
<button
type="button"
class:active={allSourcesSelected}
aria-pressed={allSourcesSelected}
onclick={selectAllSources}>Alle Quellen</button
>
{#each feeds as feed (feed.id)}
<button
type="button"
class:active={selectedFeedIds.includes(feed.id)}
aria-pressed={selectedFeedIds.includes(feed.id)}
onclick={() => toggleSource(feed.id)}>{feed.name}</button
>
{/each}
</nav>
<div class="rss-result-summary" aria-live="polite">
<strong>{filteredEntries.length} verfügbare Beiträge</strong>
<span>Seite {page} von {pageCount}</span>
</div>
<div
class="rss-entry-list"
role="listbox"
aria-label="Verfügbare RSS-Beiträge"
aria-multiselectable="true"
>
{#each visibleEntries as entry (entry.id)}
<div
class:selected={selectedIds.includes(entry.id)}
class="rss-entry"
role="option"
tabindex="0"
aria-selected={selectedIds.includes(entry.id)}
onclick={() => toggleEntry(entry.id)}
onkeydown={(event) => handleEntryKeydown(event, entry.id)}
>
<div class="rss-entry-state" aria-hidden="true">
<AppIcon name={selectedIds.includes(entry.id) ? 'check' : 'plus'} />
</div>
<div class="rss-entry-copy">
<small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
>
<strong>{entry.title}</strong>
{#if entry.summary}<p>{entry.summary}</p>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}>Original öffnen</a
>
</div>
{#if selectedIds.includes(entry.id)}<span class="rss-selected-label">Ausgewählt</span
>{/if}
</div>
{:else}<p class="empty-copy">
Für diese Quellenauswahl sind keine Beiträge verfügbar.
</p>{/each}
</div>
{#if pageCount > 1}<nav class="rss-pagination" aria-label="RSS-Beitragsseiten">
<button
class="secondary-button"
type="button"
disabled={page === 1}
onclick={() => (page -= 1)}>Zurück</button
>
<span
>{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filteredEntries.length)} von {filteredEntries.length}</span
>
<button
class="secondary-button"
type="button"
disabled={page === pageCount}
onclick={() => (page += 1)}>Weiter</button
>
</nav>{/if}
<div class="rss-category-selection">
<span class="field-label">Kategorien für die Auswahl</span>
<small>Ohne Auswahl gilt die Standardkategorie der jeweiligen Quelle.</small>
<ChoicePicker
items={categories}
selectedIds={categoryIds}
label="Kategorien"
fieldName="categoryIds"
onselectionchange={(ids) => (categoryIds = ids)}
/>
</div>
</div>
<footer class="rss-selection-actions">
<button
class="text-button danger-text"
type="submit"
formaction="?/discardFeeds"
disabled={!selectedIds.length}>Auswahl verwerfen</button
>
<button class="primary-button" type="submit" disabled={!selectedIds.length}>
Auswahl in den Eingang übernehmen ({selectedIds.length})
</button>
</footer>
</form>
</dialog>
@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
type TechnicalAccess = {
id: string;
name: string;
permissions: 'read' | 'read_write';
};
let {
form = null
}: {
form?: {
intent?: string;
error?: string;
success?: string | boolean;
technicalToken?: string;
} | null;
} = $props();
let dialog: HTMLDialogElement;
let nameInput = $state<HTMLInputElement>();
let copyMessage = $state('');
let access = $state<TechnicalAccess | null>(null);
let name = $state('');
let permissions = $state<'read' | 'read_write'>('read');
onMount(() => {
if (form?.intent === 'technicalAccess' && (form.error || form.technicalToken)) void showModal();
});
export async function showModal(selectedAccess: TechnicalAccess | null = null) {
access = selectedAccess;
name = selectedAccess?.name ?? '';
permissions = selectedAccess?.permissions ?? 'read';
if (!dialog.open) dialog.showModal();
await tick();
(form?.technicalToken && !access
? dialog.querySelector<HTMLButtonElement>('.token-copy')
: nameInput
)?.focus();
}
function close() {
dialog.close();
}
async function copyToken() {
if (!form?.technicalToken) return;
try {
await navigator.clipboard.writeText(form.technicalToken);
copyMessage = 'Token kopiert.';
} catch {
copyMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
</script>
<dialog
bind:this={dialog}
class="management-dialog technical-access-dialog"
aria-labelledby="technical-access-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="key" /></span>
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2 id="technical-access-dialog-title">{access?.name || 'API-Zugang hinzufügen'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Zugangsdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
{#if form?.technicalToken && !access}
<div class="technical-token-result">
<div class="form-message success" role="status">
<strong>Token, einmalig sichtbar</strong>
<p>Jetzt kopieren und sicher hinterlegen. Sendewerk speichert weiterhin nur den Hash.</p>
</div>
<code>{form.technicalToken}</code>
<button class="primary-button token-copy" type="button" onclick={copyToken}
><AppIcon name="copy" /> Token kopieren</button
>
{#if copyMessage}<small role="status">{copyMessage}</small>{/if}
</div>
{:else}
<form
class="management-dialog-form"
method="POST"
action={access ? '?/updateTechnicalAccess' : '?/createTechnicalAccess'}
>
{#if access}<input type="hidden" name="accessId" value={access.id} />{/if}
{#if form?.intent === 'technicalAccess' && form.error}<div
class="form-message error dialog-form-message"
role="alert"
>
{form.error}
</div>{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select bind:value={permissions} name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{access ? 'Änderungen speichern' : 'Zugang anlegen'}</button
>
</footer>
</form>
{/if}
</dialog>
+5 -1
View File
@@ -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();
+8
View File
@@ -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
+7 -1
View File
@@ -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');
});
+25
View File
@@ -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(
+16 -4
View File
@@ -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;
+32 -98
View File
@@ -1,9 +1,10 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let rssDialog: { showModal: () => void };
</script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
@@ -44,65 +45,39 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
<section class="quiet-panel feed-inbox">
<section class="quiet-panel rss-inbox-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2>Beiträge sichten</h2>
<h2>RSS-Beiträge</h2>
</div>
<span>{data.feedEntries.length} neu</span>
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
</div>
<p>Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.</p>
<p class="catalog-note">
{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.
</p>
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess}
</div>{/if}
{#if data.feedEntries.length}
<form method="POST" class="feed-selection">
<fieldset class="feed-categories">
<legend>Kategorien für die Auswahl <span>(Feed-Standard gilt ohne Auswahl)</span></legend>
{#each data.taxonomy.categories as category (category.id)}
<label
><input type="checkbox" name="categoryIds" value={category.id} />
{category.name}</label
>
{/each}
</fieldset>
<div class="feed-entry-list">
{#each data.feedEntries as entry (entry.id)}
<label class="feed-entry">
<input type="checkbox" name="entryIds" value={entry.id} />
<span
><small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
><strong>{entry.title}</strong>{#if entry.summary}<span>{entry.summary}</span>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}>Original öffnen</a
></span
>
</label>
{/each}
</div>
<div class="feed-actions">
<button class="primary-button" type="submit" formaction="?/importFeeds"
>Auswahl übernehmen</button
>
<button class="text-button danger-text" type="submit" formaction="?/discardFeeds"
>Auswahl verwerfen</button
>
</div>
</form>
{:else}
<p class="empty-copy">
Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen.
</p>
{/if}
<button
class="compact-add-button"
type="button"
disabled={!data.feedEntries.length}
onclick={() => rssDialog.showModal()}
>
<AppIcon name="list" />
{data.feedEntries.length ? 'RSS-Beiträge auswählen' : 'Keine neuen RSS-Beiträge'}
</button>
</section>
<RssSelectionDialog
bind:this={rssDialog}
entries={data.feedEntries}
categories={data.taxonomy.categories}
{form}
/>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
@@ -143,57 +118,16 @@
</div>
<style>
.feed-inbox {
.rss-inbox-panel {
display: grid;
gap: 1rem;
gap: 0.75rem;
margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
}
.feed-selection,
.feed-entry-list {
display: grid;
gap: 0.75rem;
.rss-inbox-panel .panel-heading {
margin: 0;
}
.feed-categories {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
border: 0;
padding: 0;
}
.feed-categories legend {
width: 100%;
font-weight: 700;
margin-bottom: 0.45rem;
}
.feed-categories legend span {
font-weight: 400;
color: var(--color-text-muted);
}
.feed-entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
align-items: start;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 0.7rem;
cursor: pointer;
}
.feed-entry > span {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.feed-entry small {
color: var(--color-text-muted);
}
.feed-entry a {
width: fit-content;
}
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
.rss-inbox-panel .catalog-note {
margin: 0;
}
</style>
+2
View File
@@ -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(),
+188 -129
View File
@@ -300,9 +300,10 @@
menuOpen = true;
expandedContext = null;
await tick();
liveMenuPopover
?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
?.focus({ preventScroll: true });
(
liveMenuPopover?.querySelector<HTMLElement>('[data-menu-initial-focus]') ??
liveMenuPopover?.querySelector<HTMLElement>('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 @@
</form>{/if}
</div>
<button
class="total-countdown"
type="button"
onclick={toggleTimerDisplay}
aria-label={timerDisplay === 'elapsed'
? 'Gelaufene Gesamtzeit, zu Restzeit wechseln'
: 'Geschätzte Restzeit, zu gelaufener Zeit wechseln'}
>
<span class="timer-label"
><AppIcon name="timer" /> {timerDisplay === 'elapsed' ? 'Gelaufen' : 'Restzeit'}</span
<div class="total-timer-control">
<button
class="total-countdown"
type="button"
onclick={toggleTimerDisplay}
aria-label={timerDisplay === 'elapsed'
? 'Gelaufene Gesamtzeit, zu Restzeit wechseln'
: 'Geschätzte Restzeit, zu gelaufener Zeit wechseln'}
>
<strong
>{formatCountdown(
timerDisplay === 'elapsed' ? totalElapsedSeconds : remainingSeconds
)}</strong
>
{#if timerDisplay === 'remaining' && data.live.remainingEstimate.unplannedSectionCount}
<small title="Verbleibende Abschnitte ohne geplante Abschnittsdauer"
>+ {data.live.remainingEstimate.unplannedSectionCount} ungeplant</small
<span class="timer-label"
><AppIcon name="timer" /> {timerDisplay === 'elapsed' ? 'Gelaufen' : 'Restzeit'}</span
>
{/if}
</button>
<strong
>{formatCountdown(
timerDisplay === 'elapsed' ? totalElapsedSeconds : remainingSeconds
)}</strong
>
{#if timerDisplay === 'remaining' && data.live.remainingEstimate.unplannedSectionCount}
<small title="Verbleibende Abschnitte ohne geplante Abschnittsdauer"
>+ {data.live.remainingEstimate.unplannedSectionCount} ungeplant</small
>
{/if}
</button>
{#if data.live.startedAtUtc && !data.live.endedAtUtc}<details class="timer-reset-menu">
<summary aria-label="Aktionen für Gesamtzeit"><AppIcon name="ellipsis" /></summary>
<form
method="POST"
action={liveAction('resetTotalTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Gesamtzeit zurücksetzen</button>
</form>
</details>{/if}
</div>
<div class="section-timer-control">
<div
@@ -540,6 +568,24 @@
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
</button>
</form>{/if}
{#if data.live.startedAtUtc && !data.live.endedAtUtc && data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}<details
class="timer-reset-menu"
>
<summary aria-label="Aktionen für Abschnittszeit"><AppIcon name="ellipsis" /></summary>
<form
method="POST"
action={liveAction('resetSectionTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Abschnittszeit zurücksetzen</button>
</form>
</details>{/if}
</div>
<button
@@ -567,52 +613,23 @@
inert={mobileRailOpen}
>
<section class="live-menu-show">
<small>Sendung</small>
<strong>{data.live.show.title}</strong>
</section>
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
<strong>Sendung steuern</strong>
<div class="live-menu-show-heading">
<span>
<small>Sendung</small>
<strong>{data.live.show.title}</strong>
</span>
<span class:paused={data.live.isPaused} class="live-show-state">{showStateLabel()}</span>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement
shows={[{ ...data.live.show, startsAtUtc: null }]}
shares={data.shares}
fixedShowId={data.live.show.id}
{form}
/>{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendungssteuerung</strong>
<small>Status: {data.live.show.status}</small>
{#if !data.live.startedAtUtc}
<form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Sendung starten</button
>
</form>
<form method="POST" action={liveAction('setStatus')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input
@@ -627,27 +644,16 @@
{data.live.show.status === 'ready' ? 'Als Entwurf markieren' : 'Als bereit markieren'}
</button>
</form>
<form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Sendung starten</button
>
</form>
{:else if data.live.endedAtUtc}
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen.</p>
{:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Lauf fortsetzen</button
><AppIcon name="play" /> Beendeten Lauf wiederaufnehmen</button
>
</form>
<form
@@ -679,49 +685,19 @@
</button>
{/if}
{:else}
<form
method="POST"
action={liveAction(data.live.isPaused ? 'resume' : 'pause')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
{data.live.isPaused ? 'Fortsetzen' : 'Pausieren'}
</button>
</form>
{#if data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}<form
{#if needsResume}<form
method="POST"
action={liveAction('resetSectionTimer')}
action={liveAction('start')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="arrow-rotate-left" /> Abschnittszeit zurücksetzen</button
>
<button class="secondary-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<form
method="POST"
action={liveAction('resetTotalTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="timer" /> Gesamtzeit zurücksetzen</button
>
</form>
{#if data.live.isPaused}<p class="live-menu-hint">
Fortsetzen ist direkt bei der Abschnittszeit erreichbar.
</p>{/if}
<form
method="POST"
action={liveAction('end')}
@@ -739,20 +715,103 @@
>
</form>
{/if}
{#if needsResume && !data.live.endedAtUtc}<form
method="POST"
action={liveAction('start')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
<section>
<strong>Ansicht</strong>
<div class="live-view-controls">
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
data-menu-initial-focus
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</div>
</section>
{#if data.canManageShares}<section>
<strong>Teilen</strong>
<ShareManagement
shows={[{ ...data.live.show, startsAtUtc: null }]}
shares={data.shares}
fixedShowId={data.live.show.id}
{form}
/>
</section>{/if}
<section class="live-output-section">
<details class="live-output-settings">
<summary>
<span><AppIcon name="display" /> Ausgabe einrichten</span>
<small
>{data.outputStatus === 'connected'
? 'Mit dieser Sendung verbunden'
: data.outputStatus === 'other_show'
? 'Mit einer anderen Sendung verbunden'
: 'Nicht verbunden'}</small
>
<AppIcon name="chevron-right" />
</summary>
<div class="live-output-settings-body">
<p>Der OBS-Ausgabekanal ändert sich erst durch eine der folgenden Aktionen.</p>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">
{data.outputStatus === 'connected'
? 'Ausgabe öffnen'
: 'Mit dieser Sendung verbinden und öffnen'}
</button>
</form>
{#if data.outputStatus === 'connected'}
<form
method="POST"
action={liveAction('rotateOutput')}
use:enhance={enhanceOutput}
onsubmit={(event) => {
if (
!confirm(
'Schlüssel erneuern? Bereits eingerichtete OBS-Quellen verlieren den Zugriff.'
)
)
event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form
method="POST"
action={liveAction('clearOutput')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (
!confirm(
'Ausgabekanal trennen? Verbundene Browserquellen zeigen anschließend keine Sendung.'
)
)
event.preventDefault();
}}
>
<button class="text-button danger-text" type="submit">Kanal trennen</button>
</form>
{/if}
</div>
</details>
</section>
<nav class="live-menu-navigation" aria-label="Liveansicht verlassen">
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
><AppIcon name="arrow-left" /> Zur Planung</a
>
<small>Die Sendung und öffentliche Ausgabe laufen unverändert weiter.</small>
</nav>
</div>{/if}
<div class="live-messages">
+17 -1
View File
@@ -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();
+96 -212
View File
@@ -1,12 +1,19 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import FeedEditorDialog from '#lib/components/FeedEditorDialog.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import TechnicalAccessDialog from '#lib/components/TechnicalAccessDialog.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
let feedDialog: { showModal: (feed?: (typeof data.feeds)[number] | null) => void };
let technicalAccessDialog: {
showModal: (access?: (typeof data.technicalAccesses)[number] | null) => void;
};
let managementIntent = $derived(form?.intent?.toLowerCase() ?? '');
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
@@ -20,124 +27,60 @@
<h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.error && !['share', 'technicalaccess'].some( (intent) => managementIntent.includes(intent) )}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success && !['share', 'technicalaccess'].some( (intent) => managementIntent.includes(intent) )}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-panel">
<section class="quiet-panel integration-catalog">
<div class="panel-heading">
<div>
<p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2>
</div>
<span>{data.feeds.length} eingerichtet</span>
<button class="compact-add-button" type="button" onclick={() => feedDialog.showModal()}>
<AppIcon name="plus" /> Feed hinzufügen
</button>
</div>
<p>
<p class="catalog-note">
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang.
</p>
<details class="integration-create">
<summary class="compact-add-button"><AppIcon name="plus" /> Feed hinzufügen</summary>
<form class="integration-form" method="POST" action="?/saveFeed">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
placeholder="https://example.org/feed.xml"
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value="60"
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
>
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
</details>
<div class="integration-list">
<div class="catalog-settings-list integration-settings-list">
{#each data.feeds as feed (feed.id)}
<details>
<summary
><span
><strong>{feed.name}</strong><small
<article class:inactive={!feed.isEnabled} class="catalog-setting-row">
<button
class="catalog-entry-main"
type="button"
aria-label={`${feed.name} bearbeiten`}
onclick={() => feedDialog.showModal(feed)}
>
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
<span class="catalog-entry-copy">
<strong>{feed.name}</strong>
<small
>{feed.lastError
? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'}</small
></span
><AppIcon name="pen" /></summary
>
<form class="integration-form" method="POST" action="?/saveFeed">
<input type="hidden" name="feedId" value={feed.id} />
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
value={feed.url}
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value={feed.refreshIntervalMinutes}
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={feed.defaultCategoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
abrufen</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
<div class="row-actions">
? `Zuletzt ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'} · {feed.isEnabled ? 'Automatisch' : 'Pausiert'}</small
>
</span>
<AppIcon name="pen" />
</button>
<div class="catalog-entry-actions">
<form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button"
type="submit">Jetzt abrufen</button
<input type="hidden" name="feedId" value={feed.id} />
<button type="submit" aria-label={`${feed.name} jetzt abrufen`} title="Jetzt abrufen"
><AppIcon name="arrow-rotate-right" /></button
>
</form>
<form
@@ -146,74 +89,82 @@
onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
>
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button danger-text"
type="submit">Feed löschen</button
<input type="hidden" name="feedId" value={feed.id} />
<button
class="danger-text"
type="submit"
aria-label={`${feed.name} löschen`}
title="Feed löschen"><AppIcon name="trash" /></button
>
</form>
</div>
</details>
</article>
{/each}
{#if !data.feeds.length}<p class="empty-copy">Noch keine RSS-Feeds eingerichtet.</p>{/if}
</div>
</section>
<FeedEditorDialog bind:this={feedDialog} categories={data.taxonomy.categories} />
<section class="quiet-panel integration-panel">
<section class="quiet-panel integration-catalog">
<div class="panel-heading">
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2>
</div>
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a>
<button
class="compact-add-button"
type="button"
onclick={() => technicalAccessDialog.showModal()}
>
<AppIcon name="plus" /> Zugang hinzufügen
</button>
</div>
<p>
<p class="catalog-note">
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein.
idempotent sein. <a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI öffnen</a>
</p>
{#if form?.technicalToken}<div class="token-reveal" role="status">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if}
<details class="integration-create">
<summary class="compact-add-button"><AppIcon name="plus" /> Zugang hinzufügen</summary>
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
<label
>Name<input
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
</details>
<div class="integration-list">
<div class="catalog-settings-list integration-settings-list">
{#each data.technicalAccesses as access (access.id)}
<div class="access-row">
<span
><strong>{access.name}</strong><small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
></span
>{#if !access.revokedAtUtc}<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} /><button
class="secondary-button"
type="submit">Widerrufen</button
<article class:inactive={Boolean(access.revokedAtUtc)} class="catalog-setting-row">
<button
class="catalog-entry-main"
type="button"
disabled={Boolean(access.revokedAtUtc)}
aria-label={`${access.name} bearbeiten`}
onclick={() => technicalAccessDialog.showModal(access)}
>
<span class="catalog-entry-icon"><AppIcon name="key" /></span>
<span class="catalog-entry-copy">
<strong>{access.name}</strong>
<small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
>
</form>{/if}
</div>
</span>
{#if !access.revokedAtUtc}<AppIcon name="pen" />{/if}
</button>
{#if !access.revokedAtUtc}<div class="catalog-entry-actions">
<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} />
<button
class="danger-text"
type="submit"
aria-label={`${access.name} widerrufen`}
title="Zugang widerrufen"><AppIcon name="ban" /></button
>
</form>
</div>{/if}
</article>
{/each}
{#if !data.technicalAccesses.length}<p class="empty-copy">
Noch keine technischen Zugänge angelegt.
</p>{/if}
</div>
</section>
<TechnicalAccessDialog bind:this={technicalAccessDialog} {form} />
<section class="quiet-panel account-panel">
<div class="panel-heading">
@@ -457,78 +408,11 @@
</div>
<style>
.integration-panel {
display: grid;
gap: 1rem;
.integration-catalog {
margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
}
.integration-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.8rem;
align-items: end;
}
.integration-create > summary {
width: fit-content;
cursor: pointer;
list-style: none;
}
.integration-create > summary::-webkit-details-marker {
display: none;
}
.integration-create[open] > .integration-form {
margin-top: 0.8rem;
}
.integration-form label:not(.check-row) {
display: grid;
gap: 0.35rem;
}
.check-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.6rem;
}
.integration-list {
display: grid;
gap: 0.5rem;
}
.integration-list details,
.access-row {
padding: 0.75rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.integration-list summary,
.access-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.integration-list summary > span,
.access-row > span {
display: grid;
gap: 0.2rem;
}
.integration-list small {
color: var(--text-muted);
}
.row-actions {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.token-reveal {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border: 2px solid var(--color-accent);
border-radius: 0.7rem;
overflow-wrap: anywhere;
}
.token-reveal code {
user-select: all;
.integration-catalog .catalog-note a {
white-space: nowrap;
}
</style>
+216 -3
View File
@@ -247,6 +247,80 @@ function removeCompletionFixture() {
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
}
function addRssSelectionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const now = '2026-10-26T12:00:00.000Z';
const insertFeed = database.prepare(
`INSERT INTO feed_sources
(id, name, url, is_enabled, refresh_interval_minutes, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, 1, 60, ?, ?)`
);
const insertEntry = database.prepare(
`INSERT INTO feed_entries
(id, feed_id, external_id, title, url, summary, published_at_utc, first_seen_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
database.transaction(() => {
insertFeed.run('e2e-feed-a', 'E2E Quelle A', 'https://example.org/e2e-a.xml', now, now);
insertFeed.run('e2e-feed-b', 'E2E Quelle B', 'https://example.org/e2e-b.xml', now, now);
for (let index = 1; index <= 25; index += 1) {
const published = new Date(Date.UTC(2026, 8, index)).toISOString();
insertEntry.run(
`e2e-entry-a-${index}`,
'e2e-feed-a',
`a-${index}`,
`E2E Quelle A Beitrag ${index}`,
`https://example.org/a/${index}`,
`Auszug für Beitrag ${index} aus Quelle A.`,
published,
now,
now
);
}
for (let index = 1; index <= 20; index += 1) {
const published = new Date(Date.UTC(2026, 7, index)).toISOString();
insertEntry.run(
`e2e-entry-b-${index}`,
'e2e-feed-b',
`b-${index}`,
`E2E Quelle B Beitrag ${index}`,
`https://example.org/b/${index}`,
`Auszug für Beitrag ${index} aus Quelle B.`,
published,
now,
now
);
}
})();
database.close();
}
function removeIntegrationFixtures() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
const pieceIds = database
.prepare(
"SELECT piece_id AS id FROM feed_entries WHERE id LIKE 'e2e-entry-%' AND piece_id IS NOT NULL"
)
.all() as { id: string }[];
database.prepare("DELETE FROM feed_sources WHERE id LIKE 'e2e-feed-%'").run();
for (const piece of pieceIds) database.prepare('DELETE FROM pieces WHERE id = ?').run(piece.id);
const accessIds = database
.prepare("SELECT id FROM technical_accesses WHERE name LIKE 'E2E API-Zugang%'")
.all() as { id: string }[];
for (const access of accessIds) {
database.prepare('DELETE FROM audit_events WHERE target_id = ?').run(access.id);
database.prepare('DELETE FROM technical_accesses WHERE id = ?').run(access.id);
}
})();
database.close();
}
function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
@@ -1173,6 +1247,124 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish');
});
test('rss selection and integration catalogs keep deliberate choices', async ({
page
}, testInfo) => {
addRssSelectionFixture();
try {
await page.context().route('https://example.org/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>RSS-Original</title>'
})
);
await page.setViewportSize({ width: 1100, height: 760 });
await setTheme(page, 'light');
await page.goto('/eingang');
const rssPanel = page.locator('.rss-inbox-panel');
await expect(rssPanel).toContainText('45 Beiträge sind verfügbar');
await rssPanel.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click();
const rssDialog = page.getByRole('dialog', { name: 'RSS-Beiträge auswählen' });
await expect(rssDialog).toBeVisible();
await expect(rssDialog.getByRole('option')).toHaveCount(20);
await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click();
await expect(rssDialog).toContainText('25 verfügbare Beiträge');
const firstChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 25/ });
await firstChoice.press('Enter');
await expect(firstChoice).toHaveAttribute('aria-selected', 'true');
const [original] = await Promise.all([
page.waitForEvent('popup'),
firstChoice.getByRole('link', { name: 'Original öffnen' }).click()
]);
await original.close();
await expect(firstChoice).toHaveAttribute('aria-selected', 'true');
await rssDialog.getByRole('button', { name: 'Weiter' }).click();
const secondChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 1/ });
await secondChoice.press(' ');
await expect(secondChoice).toHaveAttribute('aria-selected', 'true');
await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click();
await expect(
rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' })
).toBeEnabled();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'dark'));
await expectNoHorizontalOverflow(page);
await expect(
rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' })
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('rss-selection-mobile-dark.png') });
await rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' }).click();
await expect(page.getByRole('status')).toContainText('2 Beiträge in den Eingang übernommen');
await expect(rssPanel).toContainText('43 Beiträge sind verfügbar');
await expect(page.getByText('E2E Quelle A Beitrag 25', { exact: true })).toBeVisible();
await rssPanel.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click();
await expect(rssDialog.getByText('E2E Quelle A Beitrag 25', { exact: true })).toHaveCount(0);
await expect(rssDialog.getByText('E2E Quelle A Beitrag 1', { exact: true })).toHaveCount(0);
await rssDialog.getByRole('button', { name: 'RSS-Auswahl schließen' }).click();
await page.setViewportSize({ width: 1100, height: 800 });
await setTheme(page, 'light');
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Feed hinzufügen' }).click();
let feedDialog = page.getByRole('dialog', { name: 'Feed hinzufügen' });
await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung');
await feedDialog.getByLabel('Feed-URL').fill('https://example.org/e2e-admin.xml');
await feedDialog.getByLabel('Intervall in Minuten').fill('90');
await feedDialog.getByLabel('Standardkategorie').selectOption({ index: 1 });
await feedDialog.getByRole('button', { name: 'Feed anlegen' }).click();
const feedEntry = page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeiten' });
await expect(feedEntry).toBeVisible();
await feedEntry.click();
feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung' });
await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung bearbeitet');
await feedDialog.getByLabel('Intervall in Minuten').fill('120');
await feedDialog.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(
page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' })
).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'dark'));
await page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' }).click();
feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung bearbeitet' });
await expect(feedDialog.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('feed-editor-mobile-dark.png') });
await feedDialog.getByRole('button', { name: 'Feeddialog schließen' }).click();
await page.setViewportSize({ width: 1100, height: 800 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'light'));
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: 'Zugang hinzufügen' }).click();
let accessDialog = page.getByRole('dialog', { name: 'API-Zugang hinzufügen' });
await accessDialog.getByLabel('Name').fill('E2E API-Zugang');
await accessDialog.getByLabel('Berechtigung').selectOption('read_write');
await accessDialog.getByRole('button', { name: 'Zugang anlegen' }).click();
await expect(accessDialog.getByText('Token, einmalig sichtbar')).toBeVisible();
const token = await accessDialog.locator('code').textContent();
expect(token?.length ?? 0).toBeGreaterThan(20);
await accessDialog.getByRole('button', { name: 'Token kopieren' }).click();
await expect(accessDialog.getByText('Token kopiert.')).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(token);
await accessDialog.getByRole('button', { name: 'Zugangsdialog schließen' }).click();
await page.getByRole('button', { name: 'E2E API-Zugang bearbeiten' }).click();
accessDialog = page.getByRole('dialog', { name: 'E2E API-Zugang' });
await accessDialog.getByLabel('Name').fill('E2E API-Zugang bearbeitet');
await accessDialog.getByLabel('Berechtigung').selectOption('read');
await accessDialog.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(
page.getByRole('button', { name: 'E2E API-Zugang bearbeitet bearbeiten' })
).toContainText('Nur lesen');
await page.screenshot({
path: testInfo.outputPath('integration-catalogs-light.png'),
fullPage: true
});
} finally {
removeIntegrationFixtures();
}
});
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung');
for (const width of [390, 1280]) {
@@ -1266,8 +1458,9 @@ test('validated YouTube and TikTok players switch without exposing private field
);
await page.goto('/live?show=e2e-presentation');
await page.getByText('Menü', { exact: true }).click();
await page.getByText('Ausgabe einrichten', { exact: true }).click();
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await page.getByRole('button', { name: 'Mit dieser Sendung verbinden und öffnen' }).click();
const output = await popupPromise;
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
@@ -1360,11 +1553,23 @@ test('live control persists state and exposes only explicit presentation data',
const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' });
await expect(liveMenu).toBeVisible();
await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten');
await expect(liveMenu).toContainText('Läuft');
await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0);
await expect(liveMenu.getByRole('button', { name: 'Pausieren', exact: true })).toHaveCount(0);
await expect(liveMenu.getByRole('button', { name: 'Fortsetzen', exact: true })).toHaveCount(0);
await expect(liveMenu.getByText(/zurücksetzen/)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await page.getByRole('button', { name: 'Sendung pausieren' }).click();
await expect(page.getByRole('button', { name: 'Sendung fortsetzen' })).toBeVisible();
await liveMenuTrigger.click();
await expect(liveMenu).toContainText('Pausiert');
await expect(liveMenu.getByRole('button', { name: 'Fortsetzen', exact: true })).toHaveCount(0);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Sendung fortsetzen' }).click();
await expect(page.getByRole('button', { name: 'Sendung pausieren' })).toBeVisible();
await liveMenuTrigger.press('Enter');
await expect(liveMenu).toBeVisible();
await page.locator('.live-current').click({ position: { x: 8, y: 8 } });
@@ -1392,9 +1597,10 @@ test('live control persists state and exposes only explicit presentation data',
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await page.getByText('Menü', { exact: true }).click();
await page.getByText('Ausgabe einrichten', { exact: true }).click();
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
page.getByRole('button', { name: 'Mit dieser Sendung verbinden und öffnen' }).click()
]);
await popup.waitForURL(/\/heinzlive\/default#/);
const outputUrl = popup.url();
@@ -1712,12 +1918,19 @@ test('live control persists state and exposes only explicit presentation data',
window.open = () => null;
});
await page.getByText('Menü', { exact: true }).click();
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await page.getByText('Ausgabe einrichten', { exact: true }).click();
await page.getByRole('button', { name: 'Ausgabe öffnen' }).click();
await expect(
page
.getByRole('status')
.filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' })
).toBeVisible();
await page.getByText('Menü', { exact: true }).click();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Sendung beenden' }).click();
await page.getByText('Menü', { exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Regiemenü' })).toContainText('Lauf beendet');
await expect(page.getByRole('button', { name: 'Beendeten Lauf wiederaufnehmen' })).toBeVisible();
await anonymous.close();
await popup.close();
await expectNoHorizontalOverflow(page);