2 Commits
Author SHA1 Message Date
hnzio 64f55a8b1f Complete 0.7.1 fixes 2026-10-26 09:35:19 +01:00
hnzio a08b02c34b Release 0.7.1 2026-10-25 23:29:00 +01:00
35 changed files with 1313 additions and 194 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.0",
"version": "0.7.1",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+20
View File
@@ -1,5 +1,25 @@
# Abnahme
## Wartungsrelease 0.7.1
| Bereich | Nachweis | Stand |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Abschnittsdauer und Rückkehr zu früheren Pieces (#1) | Live-Verhaltenstests für geänderte Plandauer, Wiederaufnahme, Abschnittslaufzeit und Gesamtzeit; Chromium-Ablauf | erfüllt am 26.10.2026 |
| Mobiler Live-Ablauf und Metadaten (#3, #13) | mobile Live-Regie ohne horizontalen Überlauf; viewportgebundenes Sheet, Safe-Area-/Standalone-Basis und Fokus in Chromium | Chromium erfüllt; Safari und installierte iOS-Webapp offen |
| Mobile Sendungskarten (#5) | responsive Karten im vollständigen Chromium-Lauf bei 390 Pixeln geöffnet | erfüllt am 26.10.2026 |
| Vorbereitung nach Einplanung (#6, #15) | Migration und Servertest für konkrete Usage-Snapshots; Chromium öffnet die Usage und entfernt sie nach „sendefertig“ | erfüllt am 26.10.2026 |
| Kontextabhängige Präsentation (#8) | vier serverseitig getrennte Modi; Fundus-/Usage-Vorschauen sowie Link-, Video-, Text-/Bildausgabe in Chromium | erfüllt am 26.10.2026 |
| Gastansicht und Freigaben (#11, #12) | gestalteter Einzeldialog, Verwaltungsreihenfolge und Kartenpadding per Chromium; Tokenersatz mit Invalidierungstest | erfüllt am 26.10.2026 |
| Iconsuche (#14) | Suchfokus innerhalb der öffnenden Bedienaktion; Chromium bei mobilen und breiten Ansichten | 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`: 19 von 19 Chromium-Abläufen erfolgreich.
- Safari auf echtem iOS-Gerät: offen.
- Installierte Webapp auf echtem iOS-Gerät: separat offen.
## Etappe 7
| Kriterium | Nachweis | Stand |
+15
View File
@@ -450,3 +450,18 @@ bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium.
## Wartungskorrekturen 0.7.1
Migration `0019` ergänzt `preparation_status_snapshot` und
`preparation_effort_snapshot` an `piece_usages` und übernimmt für vorhandene
Einplanungen einmalig die damaligen Originalwerte. Danach ist ausschließlich die
konkrete Sendungsfassung maßgeblich. Einplanen, Duplizieren und bewusstes
Aktualisieren aus dem Fundus kopieren beide Werte; der Usage-Editor ändert sie
unabhängig. Die Übersicht berücksichtigt vorbereitende Nicht-Reserve-Usages
aktiver Sendungen, priorisiert zuerst deren Termin und führt direkt zum Editor.
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.
+26
View File
@@ -492,3 +492,29 @@ abgewiesen. OpenAPI 3.1 beschreibt diese Grenze maschinenlesbar. Ein MCP-Adapter
oder ein bestimmter KI-Client wird nicht vorgetäuscht: Die Anwendung bleibt ohne
KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft
werden.
## D-031: Usage-basierte Vorbereitung und rotierende Freigabelinks
Die Sendungsübersicht erzeugt keinen zusätzlichen Vorbereitungszustand. Sie
projiziert konkrete Usage-Snapshots aktiver Sendungen, deren Fundus-Original noch
`preparing` ist. So bleiben bestehende Daten und Snapshots unverändert, während
ungeplante Fundus-Arbeit aus der Sendungsübersicht verschwindet.
Weil Freigabetokens nur gehasht gespeichert werden, kann ein bestehender Link
nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben
Freigabe transaktional und löscht alle Gastsitzungen; Sichtumfang, Passwort und
Ablauf werden nicht stillschweigend verändert.
## D-032: Vorbereitung als Eigenschaft der konkreten Einplanung
Die erste 0.7.1-Umsetzung aus D-031 leitete den Vorbereitungszustand noch vom
veränderlichen Fundus-Original ab. Das widerspricht der bestehenden Kopierregel
für Sendungsfassungen und verhindert unterschiedliche Arbeitsstände bei
Mehrfachverwendung. D-032 ersetzt deshalb diesen Teil von D-031: Status und
Aufwand werden mit Migration `0019` additiv an `piece_usages` gespeichert.
Vorbereitende und sendefertige Originale dürfen eingeplant werden. Einplanung,
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`.
+30
View File
@@ -1,5 +1,35 @@
# Release Notes
## 0.7.1 - Bedienung und Live-Korrekturen
Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026
- Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder
übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre
Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1).
- Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare
Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13).
- „Noch vorzubereiten“ speichert Status und Aufwand an der konkreten Einplanung,
priorisiert aktive Sendungen nach Termin und verlinkt mit den Themenicons direkt
in den Usage-Editor. Vorbereitende Originale können regulär eingeplant werden;
Mehrfachverwendungen bleiben unabhängig (#6, #15).
- Fundus- und Usage-Editor unterscheiden Text, Nur Bild, Linkvorschau und Video
durch kontextsensitive Eingaben und passende Vorschauen (#8).
- Gast- und historische Abschnitte besitzen klar gepolsterte äußere Glaskarten.
Die Freigabeverwaltung liegt direkt vor dem Werkzeugkatalog; der gemeinsame
Dialog und seine Formfelder folgen ohne nativen Doppelrahmen dem Sendewerk-
Design. Einmalige Links lassen sich kopieren und sicher ersetzen (#11, #12).
- Die Iconsuche ist nach dem Öffnen sofort sichtbar und fokussiert (#14).
- RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte
Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog.
- Mobiles Live-Sheet und Backdrop sind am Viewport verankert. Viewport-Fit,
Standalone-Metadaten und Web-App-Manifest aktivieren iOS-Safe-Areas; ein echter
Safari-/Home-Bildschirm-Test bleibt mangels Gerät offen (#3, #13).
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Migration
`0019_usage_preparation.sql` ergänzt additive Usage-Snapshots. 60
Verhaltenstests und 19 Chromium-Abläufe sind
erfolgreich; echtes Safari und die installierte Webapp auf iOS bleiben offen.
## 0.7.0 - RSS-Import und externe Assistenz
Veröffentlicht am 25. Oktober 2026
+32
View File
@@ -8,6 +8,38 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert.
## Fehlerkorrekturen 0.7.1
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst
wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten.
- Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, lange
Metadaten verbreitern die Regie nicht mehr und das Sheet ist am mobilen
Viewport verankert. `viewport-fit=cover`, Standalone-Metadaten und Manifest
aktivieren Safe Areas auch beim Start vom Home-Bildschirm. Sendungskarten
stapeln ihre Inhalte auf kleinen Bildschirmen eindeutig.
- Die Vorbereitung liegt als eigener Status- und Aufwandsnapshot an der konkreten
Einplanung. Vorbereitende Fundus-Pieces lassen sich planen; Originaländerungen,
Duplikation und bewusste Aktualisierung folgen der bestehenden Snapshotregel.
Usage-Titel, Sendung, Abschnitt, Termin und Themenicons führen direkt in den
Usage-Editor; „sendefertig“ entfernt die Fassung aus der Übersicht.
- Text, Nur Bild, Linkvorschau und Video besitzen in Fundus- und Usage-Editor
kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim
Moduswechsel erhalten.
- Die Iconsuche fokussiert innerhalb der öffnenden Klick-/Tastaturaktion das
Suchfeld. RSS-Feeds und technische Zugänge verwenden gestaltete Formfelder.
- Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren
Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den
alten Token und dessen Gastsitzungen. Die Verwaltung zeigt Freigaben direkt
vor dem Werkzeugkatalog; der Dialog besitzt nur eine gestaltete Glasfläche.
Gast- und historische Abschnitte besitzen äußere Karten mit Innenabstand.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
19 von 19 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie
bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem
echten iOS-Gerät bleiben mangels Gerät getrennt offen.
## RSS-Import und externe Assistenz 0.7.0
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und
+17
View File
@@ -0,0 +1,17 @@
ALTER TABLE piece_usages ADD COLUMN preparation_status_snapshot TEXT NOT NULL DEFAULT 'ready'
CHECK (preparation_status_snapshot IN ('preparing', 'ready'));
ALTER TABLE piece_usages ADD COLUMN preparation_effort_snapshot TEXT
CHECK (preparation_effort_snapshot IS NULL OR preparation_effort_snapshot IN ('low', 'medium', 'high'));
UPDATE piece_usages
SET preparation_status_snapshot = CASE
WHEN EXISTS (
SELECT 1 FROM pieces p
WHERE p.id = piece_usages.original_piece_id AND p.status = 'preparing'
) THEN 'preparing'
ELSE 'ready'
END,
preparation_effort_snapshot = (
SELECT p.preparation_effort FROM pieces p WHERE p.id = piece_usages.original_piece_id
);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
"version": "0.7.0",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.7.0",
"version": "0.7.1",
"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.0",
"version": "0.7.1",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+111 -4
View File
@@ -113,10 +113,51 @@
}
.share-management {
margin-bottom: 1.5rem;
max-height: min(82dvh, 54rem);
padding: clamp(1rem, 2.5vw, 1.4rem);
overflow-y: auto;
}
.share-management-dialog {
width: min(64rem, calc(100% - 2rem));
max-width: none;
max-height: none;
margin: auto;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-large);
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
box-shadow: var(--shadow);
color: var(--text);
overflow: hidden;
backdrop-filter: blur(24px) saturate(1.12);
}
.share-management-dialog::backdrop {
background: rgb(3 8 16 / 58%);
backdrop-filter: blur(5px);
}
.share-catalog-panel {
display: grid;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding: 1rem;
}
.share-catalog-panel .panel-heading {
margin: 0;
}
.share-catalog-panel h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.share-catalog-panel .catalog-note {
margin: 0;
}
.share-create-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
@@ -133,6 +174,28 @@
font-weight: 700;
}
.share-create-form :is(input, select),
.share-edit-form :is(input, select),
.integration-form :is(input, select) {
width: 100%;
min-width: 0;
min-height: 2.65rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--field);
color: var(--text);
font: inherit;
}
.share-create-form :is(input, select):focus-visible,
.share-edit-form :is(input, select):focus-visible,
.integration-form :is(input, select):focus-visible {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 30%, transparent);
outline-offset: 1px;
}
.share-create-form button {
align-self: end;
}
@@ -262,6 +325,13 @@
overflow-wrap: anywhere;
}
.share-result > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.guest-shell {
min-height: 100dvh;
}
@@ -294,6 +364,14 @@
margin-top: 1.25rem;
}
.shared-section {
padding: clamp(1rem, 2.5vw, 1.35rem);
}
.historical-section {
padding: clamp(1rem, 2.5vw, 1.35rem);
}
.shared-section > header,
.historical-section > header,
.shared-usage-heading,
@@ -1316,6 +1394,7 @@ textarea:focus {
}
.live-controller-shell {
height: 100vh;
height: 100dvh;
min-height: 0;
margin-left: 0 !important;
@@ -1325,7 +1404,10 @@ textarea:focus {
.live-page {
display: grid;
position: relative;
isolation: isolate;
width: 100%;
height: 100vh;
height: 100dvh;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0.55rem;
@@ -1796,7 +1878,7 @@ textarea:focus {
.live-piece-heading {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: 0.35rem 0.65rem;
padding-bottom: 0.5rem;
@@ -1819,6 +1901,7 @@ textarea:focus {
}
.live-current h1 {
min-width: 0;
max-width: none;
margin: 0;
font-size: clamp(1.2rem, 2.2vw, 1.9rem);
@@ -2394,7 +2477,7 @@ textarea:focus {
z-index: 80;
inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right))
max(0.4rem, env(safe-area-inset-bottom)) auto;
width: min(22rem, calc(100vw - 0.8rem - env(safe-area-inset-left)));
width: min(22rem, calc(100% - 0.8rem - env(safe-area-inset-left)));
transform: translateX(calc(100% + 1rem));
visibility: hidden;
transition:
@@ -2431,9 +2514,16 @@ textarea:focus {
}
.live-piece-heading {
min-width: 0;
gap: 0.3rem 0.45rem;
}
.live-piece-time {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
}
.live-support-actions {
display: grid;
}
@@ -4761,11 +4851,24 @@ textarea:focus {
background: var(--panel-soft);
}
.preparation-list span {
.preparation-copy,
.preparation-copy > span:last-child {
display: grid;
min-width: 0;
}
.preparation-copy {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.6rem;
}
.preparation-topics {
display: flex;
gap: 0.2rem;
color: var(--accent-text);
}
.preparation-list small {
overflow: hidden;
color: var(--text-muted);
@@ -6742,6 +6845,10 @@ textarea:focus {
.show-card-facts {
align-items: flex-start;
}
.show-card {
flex-direction: column;
align-items: stretch;
}
.show-card-facts {
flex-direction: row;
align-items: center;
+5 -1
View File
@@ -2,8 +2,12 @@
<html lang="de" data-theme="light" data-theme-preference="system">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f4f1eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Sendewerk" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<script>
(function () {
try {
+16 -2
View File
@@ -6,6 +6,7 @@
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let searchInput: HTMLInputElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
@@ -14,18 +15,31 @@
)
);
let visible = $derived(filtered.slice(0, 120));
function openAndFocus(event: MouseEvent) {
if (menu.open) return;
event.preventDefault();
menu.open = true;
searchInput.focus({ preventScroll: true });
}
</script>
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<details
bind:this={menu}
ontoggle={() => {
if (menu.open) queueMicrotask(() => searchInput.focus());
}}
>
<summary onclick={openAndFocus}><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
><span class="sr-only">Icons durchsuchen</span><input
type="search"
placeholder="Icon suchen"
bind:value={query}
bind:this={searchInput}
/></label
>
<div class="icon-picker-grid">
@@ -62,7 +62,6 @@
</article>
{:else if content.mode === 'image'}
<article class="output-content output-image-only">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
{#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article>
{:else if content.mode === 'link'}
+82 -12
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type ShareShow = {
@@ -26,12 +27,19 @@
shows,
shares,
fixedShowId = '',
catalogPanel = false,
form = null
}: {
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
catalogPanel?: boolean;
form?: {
intent?: string;
error?: string;
success?: string | boolean;
shareUrl?: string;
} | null;
} = $props();
let visibleShows = $derived(
@@ -40,6 +48,17 @@
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
let dialog: HTMLDialogElement;
let copyMessage = $state('');
onMount(() => {
if (form?.intent?.toLowerCase().includes('share')) dialog.showModal();
});
async function copyShareUrl() {
if (!form?.shareUrl) return;
await navigator.clipboard.writeText(new URL(form.shareUrl, window.location.origin).href);
copyMessage = 'Link kopiert.';
}
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
@@ -87,21 +106,49 @@
}
</script>
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
{#if catalogPanel}
<section class="quiet-panel share-catalog-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2>Lesefreigaben</h2>
</div>
<button
class="compact-add-button share-dialog-trigger"
type="button"
aria-label="Lesefreigaben"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Freigaben verwalten
</button>
</div>
<p class="catalog-note">
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
</p>
</section>
{:else}
<button
class="secondary-button share-dialog-trigger"
type="button"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Lesefreigaben
</button>
</button>
{/if}
<dialog bind:this={dialog} class="share-management-dialog">
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
>
<section class="share-management" aria-labelledby={`share-title-${fixedShowId || 'all'}`}>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
<button
class="dialog-close"
type="button"
aria-label="Lesefreigaben schließen"
onclick={() => dialog.close()}
>
<AppIcon name="xmark" />
</button>
</div>
@@ -124,8 +171,15 @@
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<div>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a>
<a href={form.shareUrl}>{form.shareUrl}</a><button
class="secondary-button"
type="button"
onclick={copyShareUrl}><AppIcon name="copy" /> Kopieren</button
>
</div>
{#if copyMessage}<small>{copyMessage}</small>{/if}
</div>{/if}
<form class="share-create-form" method="POST" action="?/createShare">
@@ -158,7 +212,8 @@
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
>
</form>
{#if visibleShows.length}
@@ -229,6 +284,20 @@
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
{#if !share.revokedAtUtc}<form
method="POST"
action="?/replaceShare"
onsubmit={(event) => {
if (
!confirm('Ersatzlink anlegen? Der bisherige Link wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button" type="submit">Ersatzlink anlegen</button>
</form>{/if}
<form
method="POST"
action="?/deleteShare"
@@ -241,7 +310,8 @@
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
<button class="text-button danger-text" type="submit">Freigabe löschen</button
>
</form>
</div>
</details>
@@ -256,5 +326,5 @@
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</section>
</dialog>
+131 -54
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import PresentationDisplay from './PresentationDisplay.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
@@ -13,7 +14,8 @@
sectionId,
sections,
error,
enhanceCommand
enhanceCommand,
open = false
}: {
usage: PieceUsage;
revision: number;
@@ -21,6 +23,7 @@
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
open?: boolean;
} = $props();
let formId = $derived(`usage-editor-form-${usage.id}`);
@@ -30,6 +33,14 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
let selectedSourceId = $state(
untrack(
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
)
);
onMount(() => {
if (open) editor.showModal();
});
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
@@ -38,12 +49,63 @@
return Number((seconds / 60).toFixed(2));
}
function previewHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('\n', '<br>');
}
function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false;
editor.close();
}
function presentationSourceChanged(event: Event) {
selectedSourceId = (event.currentTarget as HTMLSelectElement).value;
const source = draft.sources.find((item) => item.id === selectedSourceId);
if (!source) return;
draft.presentationSourceLabel = source.label;
draft.presentationSourceUrl = source.url;
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
}
let previewContent = $derived({
mode: draft.presentationMode,
topics: draft.topics.map((topic) => topic.name),
title: draft.outputTitle || draft.title,
html: previewHtml(draft.presentation),
source: draft.presentationSourceUrl
? {
label: draft.presentationSourceLabel,
url: draft.presentationSourceUrl,
timecodeSeconds: draft.presentationSourceTimecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationMode === 'link' ? draft.presentationImageUrl : '',
siteName: draft.presentationSiteName,
provider: draft.presentationProvider,
providerId: draft.presentationProviderId,
embedUrl: '',
sources: [],
images: selectedMedia
? [{ id: selectedMedia.originalMediaId, altText: selectedMedia.altText }]
: []
});
let previewImageSrc = $derived(
selectedMedia && draft.presentationMode === 'text'
? '/media'
: selectedMedia && draft.presentationMode === 'image'
? `/media/${selectedMedia.originalMediaId}`
: ''
);
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
@@ -125,6 +187,26 @@
<details class="piece-meta-details">
<summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid">
<label
>Vorbereitungsstatus<select
name="preparationStatus"
bind:value={draft.preparationStatus}
>
<option value="preparing">In Vorbereitung</option>
<option value="ready">Sendefertig</option>
</select></label
>
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
>
<option value={null}>Nicht angegeben</option>
<option value="low">Gering</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
@@ -212,20 +294,7 @@
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<p class="field-label">Präsentationsart</p>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
@@ -238,6 +307,25 @@
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentation" value={draft.presentation} />
{/if}
<input
type="hidden"
name="presentationSourceLabel"
@@ -253,32 +341,42 @@
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
<label class="wide-field"
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
bind:value={selectedSourceId}
onchange={presentationSourceChanged}
>
<option value="">Quelle auswählen</option>
{#each draft.sources as source (source.id)}
<option value={source.id}>{source.label || source.url}</option>
{/each}
</select></label
>
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Kartentitel<input
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
{#if draft.presentationMode === 'link'}
<label
>Bild-URL<input
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
@@ -298,9 +396,11 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
{#if draft.media.length}<fieldset class="usage-media-picker">
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
class="usage-media-picker"
>
<legend>Hochgeladenes Präsentationsbild</legend>
<label
{#if draft.presentationMode === 'text'}<label
><input
type="radio"
checked={!draft.presentationMediaId}
@@ -309,7 +409,7 @@
dirty = true;
}}
/> Kein Bild</label
>
>{/if}
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
@@ -326,30 +426,7 @@
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{/if}
<PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
</aside>
</div>
</section>
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 18,
migrations: 19,
pieces: 6,
shows: 2,
usages: 5,
+82 -1
View File
@@ -16,6 +16,7 @@ import {
moveOpenUsage,
presentationChannelAccess,
presentUsage,
resumeUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent,
@@ -59,6 +60,11 @@ describe('live mode', () => {
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
)
.run();
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 600,
elapsedSeconds: 100
});
completeCurrent(
'show-october',
'timer-next-piece',
@@ -104,13 +110,54 @@ describe('live mode', () => {
.find((usage) => usage.id === 'usage-console')?.status
).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
expect(() =>
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision)
).toThrow('INVALID_STATUS');
resumeUsage('show-october', 'usage-console', 'resume-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console',
status: 'current'
});
});
it('resumes a completed piece and its earlier section timer without resetting total time', () => {
const database = getDatabase();
database
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
)
.run();
database
.prepare("UPDATE show_sections SET explicit_duration_seconds = 200 WHERE id = 'section-game'")
.run();
database
.prepare(
"UPDATE piece_usages SET show_section_id = 'section-game', position = 0 WHERE id = 'usage-effects'"
)
.run();
const start = new Date('2026-10-23T18:00:00.000Z');
startOrResumeLive('show-october', 'resume-start', 0, start);
completeCurrent('show-october', 'resume-next', 1, new Date('2026-10-23T18:01:00.000Z'));
let live = getLiveView('show-october', new Date('2026-10-23T18:01:30.000Z'));
expect(live.current?.id).toBe('usage-effects');
resumeUsage(
'show-october',
'usage-console',
'resume-earlier',
live.liveRevision,
new Date('2026-10-23T18:01:30.000Z')
);
live = getLiveView('show-october', new Date('2026-10-23T18:02:00.000Z'));
expect(live.current).toMatchObject({ id: 'usage-console', status: 'current' });
expect(live.completedCount).toBe(0);
expect(live.sectionTimer).toMatchObject({
sectionId: 'section-stories',
plannedDurationSeconds: 300,
elapsedSeconds: 90
});
expect(live.totalTimer.elapsedSeconds).toBe(120);
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key);
@@ -239,6 +286,40 @@ describe('live mode', () => {
expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
getDatabase()
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('presentation-snapshot', 'usage-console', 'presentation-image',
'Konsole auf dem Tisch', 0, 1)`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'text',
presentation_image_only_snapshot = 1,
presentation_image_url_snapshot = 'https://private.example.test/not-for-image-mode'
WHERE id = 'usage-console'`
)
.run();
presentUsage(
'show-october',
'usage-console',
'present-image-only',
getLiveView('show-october').liveRevision
);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'image',
imageUrl: '',
images: [{ id: 'presentation-snapshot', altText: 'Konsole auf dem Tisch' }],
sources: []
});
expect(await getPresentationMedia(token, 'presentation-snapshot')).toMatchObject({
mimeType: 'image/png',
byteSize: 10,
altText: 'Konsole auf dem Tisch'
});
const replacementToken = issuePresentationAccess('show-october');
expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe(
+73 -10
View File
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database
.prepare(
`SELECT s.is_reserve_bank FROM piece_usages u
`SELECT s.is_reserve_bank, u.status FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
.get(usageId, showId) as { is_reserve_bank: number; status: string } | undefined;
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
return row;
}
function closeActiveRun(database: Database.Database, showId: string, now: string) {
@@ -265,8 +266,7 @@ function syncSectionTimer(
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
)
.get(showId) as
| { id: string; section_id: string; planned_duration_seconds: number }
| undefined;
{ id: string; section_id: string; planned_duration_seconds: number } | undefined;
if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now);
@@ -279,6 +279,29 @@ function syncSectionTimer(
}
if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const previous = database
.prepare(
`SELECT id FROM live_section_timers WHERE show_id = ? AND section_id = ?
ORDER BY started_at_utc DESC LIMIT 1`
)
.get(showId, section.id) as { id: string } | undefined;
if (previous) {
database
.prepare(
`UPDATE live_section_timers SET planned_duration_seconds = ?, ended_at_utc = NULL
WHERE id = ?`
)
.run(section.explicit_duration_seconds, previous.id);
if (showTimerRunning(database, showId)) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), previous.id, now);
}
return;
}
const timerId = randomUUID();
database
.prepare(
@@ -287,12 +310,14 @@ function syncSectionTimer(
VALUES (?, ?, ?, ?, ?)`
)
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
if (showTimerRunning(database, showId)) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timerId, now);
}
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
@@ -412,6 +437,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
const state = ensureLiveState(database, showId, now);
const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND');
if (state.started_at_utc && !state.ended_at_utc && state.current_usage_id) {
syncSectionTimer(database, showId, state.current_usage_id, now);
}
const durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({
...section,
@@ -941,7 +969,8 @@ export function selectUsage(
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
usageBelongsToShow(database, showId, usageId);
const target = usageBelongsToShow(database, showId, usageId);
if (target.status !== 'open') throw new Error('INVALID_STATUS');
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
@@ -955,6 +984,36 @@ export function selectUsage(
);
}
export function resumeUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume-usage',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
const target = usageBelongsToShow(database, showId, usageId);
if (target.status !== 'done' && target.status !== 'skipped')
throw new Error('INVALID_STATUS');
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
@@ -1165,10 +1224,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const mode = usage.presentation_mode_snapshot;
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
mode,
topics: (
database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
@@ -1185,7 +1245,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
: null,
previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot,
imageUrl: mode === 'link' ? usage.presentation_image_url_snapshot : '',
siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot,
@@ -1195,7 +1255,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot
),
sources: [],
images: (
images: ['text', 'image'].includes(mode)
? (
database
.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
@@ -1203,13 +1264,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
)
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text }))
: []
}
};
}
export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null;
if (!view?.content?.imageUrl || view.content.mode !== 'link') return null;
try {
return await fetchPresentationImage(view.content.imageUrl);
} catch {
@@ -1233,7 +1295,8 @@ export async function getPresentationMedia(
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1
AND (u.presentation_image_only_snapshot = 1 OR u.presentation_mode_snapshot = 'text')`
)
.get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
+11 -1
View File
@@ -13,6 +13,7 @@ import {
getSharedMedia,
getSharedShow,
revokeShare,
replaceShareToken,
updateShare
} from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js';
@@ -138,8 +139,17 @@ describe('show shares', () => {
expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
const replacementToken = replaceShareToken(actor, shared.id, created.id);
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
expect(getSharedShow(replacementToken).status).toBe('password');
const replacementSession = await authenticateShare(
replacementToken,
'neues-freigabe-passwort',
'192.0.2.20'
);
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('unavailable');
});
it('projects exactly the selected information scope', async () => {
+21
View File
@@ -193,6 +193,27 @@ export async function updateShare(
})();
}
export function replaceShareToken(
actor: AuthUser,
shareId: string,
expectedShowId?: string
): string {
requireBarkeeperActor(actor);
const token = createToken();
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare(
`UPDATE show_shares SET token_hash = ?
WHERE id = ? AND revoked_at_utc IS NULL${expectedShowId ? ' AND show_id = ?' : ''}`
)
.run(hashToken(token), ...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
return token;
}
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor);
const result = getDatabase()
+46
View File
@@ -13,6 +13,7 @@ import {
duplicatePieceUsage,
getPieceUsageContexts,
getShow,
listPreparationUsages,
listShowTemplates,
movePieceUsage,
moveShowSection,
@@ -60,12 +61,57 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId,
preparationStatus: edited.preparationStatus,
preparationEffort: edited.preparationEffort,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds
};
}
describe('show builder data layer', () => {
it('tracks preparation on the concrete usage independently from its original', () => {
const database = getDatabase();
database
.prepare(
"UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'"
)
.run();
database.prepare("UPDATE pieces SET status = 'preparing' WHERE id = 'piece-sounds'").run();
const show = getShow('show-october')!;
const planned = addPieceUsage(show.id, show.revision, {
pieceId: 'piece-console',
sectionId: show.sections[0].id
});
database
.prepare(
"UPDATE pieces SET status = 'ready', preparation_effort = 'low' WHERE id = 'piece-console'"
)
.run();
const usages = listPreparationUsages();
expect(usages.map((usage) => usage.usageId)).toEqual([planned.id]);
expect(usages[0]).toMatchObject({
showId: 'show-october',
showTitle: 'Herbstsendung: Kleine Welten',
sectionName: show.sections[0].name,
title: 'Die kleine Konsole mit dem großen Dreh',
preparationEffort: 'high',
topics: [{ name: 'Gaming' }]
});
expect(usages.some((usage) => usage.title === 'Drei Sounds, ein Spiel')).toBe(false);
const plannedUsage = getShow(show.id)!.sections[0].usages.find(
(usage) => usage.id === planned.id
)!;
updatePieceUsage(
show.id,
planned.id,
planned.revision,
editedUsage(plannedUsage, { preparationStatus: 'ready' })
);
expect(listPreparationUsages()).toEqual([]);
});
it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
+170 -25
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { createPiece } from './content.js';
import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
import { detectVideoProvider, type PresentationMode, type VideoProvider } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
@@ -10,6 +10,8 @@ export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
export type ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type UsagePreparationStatus = 'preparing' | 'ready';
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type ShowListItem = {
id: string;
@@ -21,6 +23,17 @@ export type ShowListItem = {
sectionCount: number;
};
export type PreparationUsage = {
usageId: string;
showId: string;
showTitle: string;
showStartsAtUtc: string | null;
sectionName: string;
title: string;
preparationEffort: PreparationEffort;
topics: UsageTopic[];
};
export type ShowTemplateSection = {
id: string;
name: string;
@@ -42,6 +55,8 @@ export type PieceUsage = {
originalPieceId: string | null;
position: number;
status: UsageStatus;
preparationStatus: UsagePreparationStatus;
preparationEffort: PreparationEffort;
durationSeconds: number;
title: string;
outputTitle: string;
@@ -59,7 +74,7 @@ export type PieceUsage = {
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProvider: VideoProvider | '';
presentationProviderId: string;
ownNotes: string;
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
@@ -175,6 +190,8 @@ export type PieceUsageUpdate = {
presentationSiteName: string;
ownNotes: string;
durationSeconds: number;
preparationStatus?: UsagePreparationStatus;
preparationEffort?: PreparationEffort;
tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null;
};
@@ -220,6 +237,8 @@ type UsageRow = {
original_piece_id: string | null;
position: number;
status: UsageStatus;
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
duration_seconds: number;
title_snapshot: string;
output_title_snapshot: string;
@@ -238,7 +257,7 @@ type UsageRow = {
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: string;
presentation_provider_snapshot: VideoProvider | '';
presentation_provider_id_snapshot: string;
own_notes: string;
};
@@ -459,6 +478,8 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
originalPieceId: row.original_piece_id,
position: row.position,
status: row.status,
preparationStatus: row.preparation_status_snapshot,
preparationEffort: row.preparation_effort_snapshot,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
outputTitle: row.output_title_snapshot,
@@ -769,6 +790,50 @@ export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
}));
}
export function listPreparationUsages(): PreparationUsage[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
u.title_snapshot, u.preparation_effort_snapshot
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status IN ('draft', 'ready', 'live')
AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc,
CASE u.preparation_effort_snapshot
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END,
s.position, u.position`
)
.all() as {
usage_id: string;
show_id: string;
show_title: string;
show_starts_at_utc: string | null;
section_name: string;
title_snapshot: string;
preparation_effort_snapshot: PreparationUsage['preparationEffort'];
}[];
const topics = database.prepare(
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
);
return rows.map((row) => ({
usageId: row.usage_id,
showId: row.show_id,
showTitle: row.show_title,
showStartsAtUtc: row.show_starts_at_utc,
sectionName: row.section_name,
title: row.title_snapshot,
preparationEffort: row.preparation_effort_snapshot,
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
name: topic.name,
iconName: topic.icon_name
}))
}));
}
export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase();
const templates = database
@@ -821,6 +886,7 @@ export function getShow(id: string): Show | null {
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.preparation_status_snapshot, u.preparation_effort_snapshot,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
@@ -1083,8 +1149,9 @@ export function duplicateCompletedShow(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
@@ -1127,6 +1194,8 @@ export function duplicateCompletedShow(
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot,
usage.preparation_status_snapshot,
usage.preparation_effort_snapshot,
usage.own_notes,
usage.is_reserve
);
@@ -1306,7 +1375,8 @@ export function addPieceUsage(
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.preparation_effort
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?`
)
@@ -1332,6 +1402,7 @@ export function addPieceUsage(
presentation_provider: string;
presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
@@ -1342,7 +1413,7 @@ export function addPieceUsage(
) {
throw new Error('NOT_FOUND');
}
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
if (!['preparing', 'ready'].includes(piece.status)) throw new Error('PIECE_NOT_READY');
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
@@ -1359,8 +1430,9 @@ export function addPieceUsage(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -1387,6 +1459,8 @@ export function addPieceUsage(
piece.presentation_site_name,
piece.presentation_provider,
piece.presentation_provider_id,
piece.status,
piece.preparation_effort,
input.sectionId === null ? 1 : 0
);
snapshotPieceRelations(database, id, input.pieceId);
@@ -1468,6 +1542,7 @@ export function duplicatePieceUsage(
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?`
)
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
@@ -1490,8 +1565,9 @@ export function duplicatePieceUsage(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -1518,6 +1594,8 @@ export function duplicatePieceUsage(
source.presentation_site_name_snapshot,
source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot,
source.preparation_status_snapshot,
source.preparation_effort_snapshot,
source.own_notes,
source.is_reserve
);
@@ -1546,7 +1624,8 @@ export function refreshPieceUsageFromOriginal(
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.status, p.preparation_effort
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position
@@ -1573,9 +1652,12 @@ export function refreshPieceUsageFromOriginal(
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
}
| undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
@@ -1585,7 +1667,8 @@ export function refreshPieceUsageFromOriginal(
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?`
presentation_provider_id_snapshot = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(
source.title,
@@ -1608,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
source.presentation_site_name,
source.presentation_provider,
source.presentation_provider_id,
source.status,
source.preparation_effort,
usageId
);
snapshotPieceRelations(
@@ -1632,6 +1717,18 @@ export function updatePieceUsage(
const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!;
if (
input.preparationStatus !== undefined &&
!['preparing', 'ready'].includes(input.preparationStatus)
) {
throw new Error('INVALID_PREPARATION_STATUS');
}
if (
input.preparationEffort !== undefined &&
![null, 'low', 'medium', 'high'].includes(input.preparationEffort)
) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
@@ -1642,7 +1739,7 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
};
const presentation = validPresentation(
const submittedPresentation = validPresentation(
input.presentationMode,
input.presentationSourceLabel,
input.presentationSourceUrl,
@@ -1656,6 +1753,57 @@ export function updatePieceUsage(
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
const currentPreparation = database
.prepare(
`SELECT preparation_status_snapshot, preparation_effort_snapshot
FROM piece_usages WHERE id = ?`
)
.get(usageId) as {
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
};
let presentation = submittedPresentation;
if (presentation.mode === 'link' || presentation.mode === 'video') {
const source = database
.prepare(
`SELECT label, url, timecode_seconds FROM usage_source_snapshots
WHERE usage_id = ? AND url = ?`
)
.get(usageId, presentation.sourceUrl) as
{ label: string; url: string; timecode_seconds: number | null } | undefined;
if (!source) throw new Error('INVALID_PRESENTATION');
presentation = validPresentation(
presentation.mode,
source.label,
source.url,
source.timecode_seconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName
);
}
const selectedMediaId =
input.presentationMediaId === undefined
? ((
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(usageId) as { id: string } | undefined
)?.id ?? null)
: input.presentationMediaId;
if (
selectedMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(selectedMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
if (presentation.mode === 'image' && !selectedMediaId) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
@@ -1665,7 +1813,8 @@ export function updatePieceUsage(
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?`
own_notes = ?, duration_seconds = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(
title,
@@ -1689,6 +1838,10 @@ export function updatePieceUsage(
presentation.providerId,
values.ownNotes,
duration,
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
input.preparationEffort === undefined
? currentPreparation.preparation_effort_snapshot
: input.preparationEffort,
usageId
);
if (input.tools) {
@@ -1721,23 +1874,15 @@ export function updatePieceUsage(
}
}
if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId);
if (input.presentationMediaId !== null) {
if (selectedMediaId !== null) {
database
.prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
)
.run(input.presentationMediaId, usageId);
.run(selectedMediaId, usageId);
}
}
return result;
+73 -37
View File
@@ -77,14 +77,19 @@
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: '',
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : []
images:
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? [{ id: selectedMedia.id, altText: selectedMedia.altText }]
: []
});
let presentationPreviewImage = $derived(
selectedMedia
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? draft.presentationMode === 'text'
? '/media'
: `/media/${selectedMedia.id}`
: sourcePreviewImage
: draft.presentationMode === 'link'
? sourcePreviewImage
: ''
);
let pendingHref: string | null = null;
let allowNavigation = false;
@@ -116,8 +121,8 @@
if (
control instanceof HTMLInputElement &&
control.name === 'presentationMode' &&
control.value !== 'text' &&
draft.presentationSourcePosition === null
((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
(control.value === 'image' && !draft.presentationMediaId))
) {
return;
}
@@ -348,10 +353,11 @@
/></label
>
<label
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option
value="medium">Mittel</option
><option value="high">Hoch</option></select
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option></select
></label
>
<label
@@ -533,21 +539,7 @@
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<p class="field-label">Präsentationsart</p>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
@@ -558,9 +550,30 @@
/>{mode[1]}</label
>{/each}
</div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentationText" value={draft.presentationText} />
{/if}
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<label
>Quelle<select
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
@@ -571,7 +584,7 @@
></label
>
<label
>Kartentitel<input
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
@@ -579,16 +592,24 @@
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
{#if draft.presentationMode === 'link'}
<label
>Bild-URL<input
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
placeholder="Wird aus der Linkvorschau geladen"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
@@ -612,6 +633,20 @@
>
</div>
</div>
{:else}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
@@ -638,6 +673,7 @@
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
@@ -646,7 +682,11 @@
</section>
</form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}>
<section
class="piece-editor-panel piece-media-panel"
hidden={activeSection !== 'presentation' ||
!['text', 'image'].includes(draft.presentationMode)}
>
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Hochgeladene Bilder</p>
@@ -654,12 +694,10 @@
</div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div>
{#if data.piece.media.length}<label class="media-none"
{#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value=""
name="presentationMediaChoice"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
@@ -668,13 +706,11 @@
/> Kein hochgeladenes Bild</label
>
<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
{#each draft.media as media (media.id)}<figure>
<label class="media-presentation-choice"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value={media.id}
name="presentationMediaChoice"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
@@ -14,10 +14,12 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay
content={data.content}
imageSrc={data.content.images.length
? data.content.mode === 'text'
imageSrc={data.content.mode === 'text' && data.content.images.length
? '/media'
: `/media/${data.content.images[0].id}`
: `/fundus/${data.pieceId}/praesentation/preview-image`}
: data.content.mode === 'image' && data.content.images.length
? `/media/${data.content.images[0].id}`
: data.content.mode === 'link' && data.content.imageUrl
? `/fundus/${data.pieceId}/praesentation/preview-image`
: ''}
/>
</main>
@@ -7,7 +7,9 @@ import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar');
if (piece?.presentationMode !== 'link' || !piece.presentationImageUrl) {
error(404, 'Vorschaubild nicht verfügbar');
}
try {
const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), {
+53 -6
View File
@@ -16,6 +16,7 @@ import {
rotatePresentationChannelKey,
resetSectionTimer,
resetTotalTimer,
resumeUsage,
resumeEndedLive,
resumeLive,
selectUsage,
@@ -25,7 +26,13 @@ import {
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) {
@@ -99,10 +106,16 @@ export const actions = {
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'),
showId: text(data, 'showId'),
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` };
return {
intent: 'createShare',
success: 'Leselink angelegt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem });
@@ -112,16 +125,38 @@ export const actions = {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, text(data, 'shareId'), {
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'),
await updateShare(
actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
}, text(data, 'showId'));
},
text(data, 'showId')
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem });
}
},
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), text(data, 'showId'));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'replaceShare', ...problem });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
@@ -253,6 +288,18 @@ export const actions = {
)
);
},
resumeUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resumeUsage', () =>
resumeUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+43 -3
View File
@@ -591,7 +591,12 @@
</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}
{#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>
@@ -814,8 +819,13 @@
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}>
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')}
{#if viewedSection?.twitchTags.length}<button
class="text-button live-twitch-tags"
type="button"
onclick={copyTwitchTags}
>
<AppIcon name="copy" />
{viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
@@ -1000,6 +1010,22 @@
>
</button>
<div class="live-run-actions">
{#if usage.status === 'done' || usage.status === 'skipped'}<form
method="POST"
action={liveAction('resumeUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="text-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}
>Wiederaufnehmen</button
>
</form>{/if}
{#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary
@@ -1176,6 +1202,19 @@
: 'Präsentieren'}
</button>
</form>
{#if (viewedUsage.status === 'done' || viewedUsage.status === 'skipped') && !viewingLiveCurrent}
<form method="POST" action={liveAction('resumeUsage')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="primary-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}>Wiederaufnehmen</button
>
</form>
{:else}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -1208,6 +1247,7 @@
: 'Erledigen & Sendungsende'}
</button>
</form>
{/if}
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if}
</div>
+7 -3
View File
@@ -2,8 +2,12 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
import { listPieces } from '#lib/server/content.js';
import {
createShow,
listPreparationUsages,
listShows,
listShowTemplates
} from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => {
@@ -13,7 +17,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(['draft', 'ready', 'live']),
preparationPieces: listPieces({ statuses: ['preparing'] }),
preparationUsages: listPreparationUsages(),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
+38 -6
View File
@@ -53,6 +53,10 @@
}).format(new Date(`${value}T12:00:00Z`));
}
function preparationHref(showId: string, usageId: string) {
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
}
function statusLabel(status: string) {
if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf';
@@ -125,13 +129,41 @@
</form>
</details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
{#if data.preparationUsages.length}<section
class="show-library preparation-overview"
aria-labelledby="preparation-title"
>
<header>
<h2 id="preparation-title">Noch vorzubereiten</h2>
<span>{data.preparationUsages.length}</span>
</header>
<div class="preparation-list">
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span>
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if}
</a>{/each}
{#each data.preparationUsages as usage (usage.usageId)}
<!-- The helper starts from a route resolved against the configured base path. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={preparationHref(usage.showId, usage.usageId)}>
<div class="preparation-copy">
<span
class="preparation-topics"
aria-label={usage.topics.map((topic) => topic.name).join(', ')}
>{#each usage.topics as topic (topic.name)}<AppIcon
name={topic.iconName}
/>{/each}</span
>
<span
><strong>{usage.title}</strong><small>{usage.showTitle} · {usage.sectionName}</small
></span
>
</div>
{#if usage.preparationEffort}<b class={`effort ${usage.preparationEffort}`}
>{usage.preparationEffort === 'low'
? 'Gering'
: usage.preparationEffort === 'medium'
? 'Mittel'
: 'Hoch'}</b
>{/if}
</a>
{/each}
</div>
</section>{/if}
+25 -2
View File
@@ -4,7 +4,13 @@ import type { Actions, PageServerLoad } from './$types';
import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import {
addPieceUsage,
@@ -123,7 +129,7 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
};
}
const pieces = listPieces({
statuses: ['ready'],
statuses: ['preparing', 'ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
});
@@ -192,6 +198,20 @@ export const actions = {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
}
},
replaceShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), params.id);
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'replaceShare', ...actionError(cause) });
}
},
deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
@@ -318,6 +338,9 @@ export const actions = {
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'),
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
preparationEffort: (text(data, 'preparationEffort') ||
null) as PieceUsageUpdate['preparationEffort'],
presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({
id,
+8 -2
View File
@@ -431,7 +431,7 @@
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
<h2 id="source-title">Planbarer Fundus</h2>
</div>
<span>{visiblePieces.length}</span>
</header>
@@ -661,6 +661,7 @@
{#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)}
<div
id={`usage-${usage.id}`}
class="usage-drag-slot"
role="presentation"
ondragover={(event) => {
@@ -750,6 +751,7 @@
</form>
<UsageEditorDialog
{usage}
open={page.url.searchParams.get('usage') === usage.id}
revision={data.show.revision}
sectionId={section.id}
sections={data.show.sections}
@@ -893,7 +895,11 @@
placeholder="optional, Sekunden"
/></label
><label
>Twitch-Tags<input name="twitchTags" maxlength="300" placeholder="Gaming, Deutsch" /></label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
placeholder="Gaming, Deutsch"
/></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form>
+21 -1
View File
@@ -8,7 +8,13 @@ import {
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
@@ -118,6 +124,20 @@ export const actions = {
return fail(400, { intent: 'updateShare', error: actionError(cause) });
}
},
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, String(data.get('shareId') || ''));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(400, { intent: 'replaceShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
+31 -6
View File
@@ -32,7 +32,7 @@
>
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
@@ -45,6 +45,8 @@
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
@@ -79,6 +81,7 @@
>
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
</details>
<div class="integration-list">
{#each data.feeds as feed (feed.id)}
<details>
@@ -169,9 +172,16 @@
{#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
>Name<input
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select name="permissions"
@@ -182,6 +192,7 @@
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
</details>
<div class="integration-list">
{#each data.technicalAccesses as access (access.id)}
<div class="access-row">
@@ -300,7 +311,8 @@
</section>
</div>
<h2 class="section-heading">Werkzeuge</h2>
<h2 class="section-heading">Werkzeuge & Freigaben</h2>
<ShareManagement shows={data.shows} shares={data.shares} {form} catalogPanel />
<section class="quiet-panel tool-management">
<div class="panel-heading">
<div>
@@ -456,6 +468,17 @@
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;
@@ -472,8 +495,10 @@
}
.integration-list details,
.access-row {
border-top: 1px solid var(--color-border);
padding-top: 0.8rem;
padding: 0.75rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.integration-list summary,
.access-row {
@@ -488,7 +513,7 @@
gap: 0.2rem;
}
.integration-list small {
color: var(--color-text-muted);
color: var(--text-muted);
}
.row-actions {
display: flex;
+11
View File
@@ -0,0 +1,11 @@
{
"name": "Sendewerk",
"short_name": "Sendewerk",
"description": "Redaktion, Sendungsplanung und Live-Regie für heinzlive",
"start_url": "/sendungen",
"display": "standalone",
"background_color": "#0b1220",
"theme_color": "#0b1220",
"lang": "de",
"orientation": "any"
}
+101 -7
View File
@@ -364,6 +364,23 @@ function setSectionDuration(sectionId: string, seconds: number | null) {
database.close();
}
function setUsagePreparation(
usageId: string,
status: 'preparing' | 'ready',
effort: 'low' | 'medium' | 'high' | null
) {
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'));
database
.prepare(
`UPDATE piece_usages SET preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(status, effort, usageId);
database.close();
}
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('/');
@@ -665,7 +682,6 @@ test('show overview switches persistently between list, calendar and mobile agen
const octoberDay = page
.locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
@@ -703,6 +719,43 @@ test('show overview switches persistently between list, calendar and mobile agen
}
});
test('preparation opens and updates the concrete scheduled usage', async ({ page }) => {
setUsagePreparation('usage-console', 'preparing', 'high');
try {
await page.goto('/sendungen');
const preparation = page.locator('.preparation-overview');
await expect(preparation).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(preparation).toContainText('Hoch');
await preparation.getByRole('link', { name: /Die kleine Konsole/ }).click();
await expect(page).toHaveURL(/usage=usage-console/);
const editor = page.getByRole('dialog', {
name: 'Die kleine Konsole mit dem großen Dreh'
});
await expect(editor).toBeVisible();
await editor.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(editor.getByRole('radio', { name: 'Text', exact: true })).toBeChecked();
await editor.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toHaveCount(0);
await editor.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Kartentitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Video', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Video-URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Videotitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Text', exact: true }).check();
await editor.getByRole('button', { name: 'Sendungstext', exact: true }).click();
await editor.getByText('Dauer und Einplanung', { exact: true }).click();
await editor.getByLabel('Vorbereitungsstatus').selectOption('ready');
await editor.getByRole('button', { name: 'Verwendung speichern' }).click();
await page.goto('/sendungen');
await expect(page.locator('.preparation-overview')).toHaveCount(0);
} finally {
setUsagePreparation('usage-console', 'ready', null);
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
@@ -1106,6 +1159,7 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible();
await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused();
const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
@@ -1276,6 +1330,14 @@ test('live control persists state and exposes only explicit presentation data',
});
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Nur Bild', exact: true })).toBeChecked();
await page.getByRole('radio', { name: 'Text', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.goto('/sendungen/show-october');
const plannedConsole = page
.locator('.editable-usage')
@@ -1414,8 +1476,11 @@ test('live control persists state and exposes only explicit presentation data',
.locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled();
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled();
await expect(
page.locator('.live-action-bar').getByRole('button', { name: 'Wiederaufnehmen', exact: true })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Erledigen/ })).toHaveCount(0);
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
@@ -1737,10 +1802,31 @@ test('completion, archive and protected guest sharing work end to end', async ({
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
expect(
await page
.locator('.historical-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await page.goto('/verwaltung');
const shareCatalog = page.locator('.share-catalog-panel');
await expect(shareCatalog).toBeVisible();
const catalogOrder = await page.evaluate(() => {
const share = document.querySelector('.share-catalog-panel')?.getBoundingClientRect();
const tools = document.querySelector('.tool-management')?.getBoundingClientRect();
return share && tools ? share.bottom <= tools.top : false;
});
expect(catalogOrder).toBe(true);
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management');
await expect(page.locator('.share-management-dialog')).toBeVisible();
const dialogStyle = await page.locator('.share-management-dialog').evaluate((element) => ({
borderRadius: Number.parseFloat(getComputedStyle(element).borderRadius),
background: getComputedStyle(element).backgroundColor
}));
expect(dialogStyle.borderRadius).toBeGreaterThan(0);
expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
@@ -1768,6 +1854,12 @@ test('completion, archive and protected guest sharing work end to end', async ({
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
expect(
await guestPage
.locator('.shared-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
@@ -1786,6 +1878,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first();
@@ -1854,7 +1947,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
);
await openEditorialField(page, 'Präsentationstext');
await page
.getByRole('textbox', { name: 'Öffentlicher Text', exact: true })
.getByRole('textbox', { name: 'Öffentlicher Vorspann', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
@@ -2002,7 +2095,7 @@ test('a quickly captured video can be written and previewed before classificatio
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
@@ -2028,9 +2121,10 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await page.getByLabel('Vorschaubild-URL').fill('');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
@@ -2046,7 +2140,7 @@ test('link preview uses one existing source, persists manual fields and isolates
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(1);
await expect(preview.locator('.output-link-image')).toHaveCount(0);
await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true