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 1613 additions and 494 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.7.0", "version": "0.7.1",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+20
View File
@@ -1,5 +1,25 @@
# Abnahme # 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 ## Etappe 7
| Kriterium | Nachweis | Stand | | 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 und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte `image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium. 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 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 KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft
werden. 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 # 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 ## 0.7.0 - RSS-Import und externe Assistenz
Veröffentlicht am 25. Oktober 2026 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 Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert. 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 ## RSS-Import und externe Assistenz 0.7.0
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und - 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", "name": "sendewerk",
"version": "0.7.0", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.0", "version": "0.7.1",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+111 -4
View File
@@ -113,10 +113,51 @@
} }
.share-management { .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; 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 { .share-create-form {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
@@ -133,6 +174,28 @@
font-weight: 700; 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 { .share-create-form button {
align-self: end; align-self: end;
} }
@@ -262,6 +325,13 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.share-result > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.guest-shell { .guest-shell {
min-height: 100dvh; min-height: 100dvh;
} }
@@ -294,6 +364,14 @@
margin-top: 1.25rem; 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, .shared-section > header,
.historical-section > header, .historical-section > header,
.shared-usage-heading, .shared-usage-heading,
@@ -1316,6 +1394,7 @@ textarea:focus {
} }
.live-controller-shell { .live-controller-shell {
height: 100vh;
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 0;
margin-left: 0 !important; margin-left: 0 !important;
@@ -1325,7 +1404,10 @@ textarea:focus {
.live-page { .live-page {
display: grid; display: grid;
position: relative;
isolation: isolate;
width: 100%; width: 100%;
height: 100vh;
height: 100dvh; height: 100dvh;
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0.55rem; gap: 0.55rem;
@@ -1796,7 +1878,7 @@ textarea:focus {
.live-piece-heading { .live-piece-heading {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline; align-items: baseline;
gap: 0.35rem 0.65rem; gap: 0.35rem 0.65rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1819,6 +1901,7 @@ textarea:focus {
} }
.live-current h1 { .live-current h1 {
min-width: 0;
max-width: none; max-width: none;
margin: 0; margin: 0;
font-size: clamp(1.2rem, 2.2vw, 1.9rem); font-size: clamp(1.2rem, 2.2vw, 1.9rem);
@@ -2394,7 +2477,7 @@ textarea:focus {
z-index: 80; z-index: 80;
inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right)) 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; 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)); transform: translateX(calc(100% + 1rem));
visibility: hidden; visibility: hidden;
transition: transition:
@@ -2431,9 +2514,16 @@ textarea:focus {
} }
.live-piece-heading { .live-piece-heading {
min-width: 0;
gap: 0.3rem 0.45rem; gap: 0.3rem 0.45rem;
} }
.live-piece-time {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
}
.live-support-actions { .live-support-actions {
display: grid; display: grid;
} }
@@ -4761,11 +4851,24 @@ textarea:focus {
background: var(--panel-soft); background: var(--panel-soft);
} }
.preparation-list span { .preparation-copy,
.preparation-copy > span:last-child {
display: grid; display: grid;
min-width: 0; 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 { .preparation-list small {
overflow: hidden; overflow: hidden;
color: var(--text-muted); color: var(--text-muted);
@@ -6742,6 +6845,10 @@ textarea:focus {
.show-card-facts { .show-card-facts {
align-items: flex-start; align-items: flex-start;
} }
.show-card {
flex-direction: column;
align-items: stretch;
}
.show-card-facts { .show-card-facts {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
+5 -1
View File
@@ -2,8 +2,12 @@
<html lang="de" data-theme="light" data-theme-preference="system"> <html lang="de" data-theme="light" data-theme-preference="system">
<head> <head>
<meta charset="utf-8" /> <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="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> <script>
(function () { (function () {
try { try {
+16 -2
View File
@@ -6,6 +6,7 @@
$props(); $props();
let query = $state(''); let query = $state('');
let menu: HTMLDetailsElement; let menu: HTMLDetailsElement;
let searchInput: HTMLInputElement;
let filtered = $derived( let filtered = $derived(
classicRegularIcons.filter((icon) => classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}` `${icon.name} ${icon.label} ${icon.terms.join(' ')}`
@@ -14,18 +15,31 @@
) )
); );
let visible = $derived(filtered.slice(0, 120)); 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> </script>
<div class="icon-picker"> <div class="icon-picker">
<input type="hidden" name={inputName} value={name} /> <input type="hidden" name={inputName} value={name} />
<details bind:this={menu}> <details
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary> 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"> <div class="icon-picker-popover">
<label <label
><span class="sr-only">Icons durchsuchen</span><input ><span class="sr-only">Icons durchsuchen</span><input
type="search" type="search"
placeholder="Icon suchen" placeholder="Icon suchen"
bind:value={query} bind:value={query}
bind:this={searchInput}
/></label /></label
> >
<div class="icon-picker-grid"> <div class="icon-picker-grid">
@@ -62,7 +62,6 @@
</article> </article>
{:else if content.mode === 'image'} {:else if content.mode === 'image'}
<article class="output-content output-image-only"> <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} {#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article> </article>
{:else if content.mode === 'link'} {:else if content.mode === 'link'}
+230 -160
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte'; import AppIcon from './AppIcon.svelte';
type ShareShow = { type ShareShow = {
@@ -26,12 +27,19 @@
shows, shows,
shares, shares,
fixedShowId = '', fixedShowId = '',
catalogPanel = false,
form = null form = null
}: { }: {
shows: ShareShow[]; shows: ShareShow[];
shares: ShareItem[]; shares: ShareItem[];
fixedShowId?: string; 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(); } = $props();
let visibleShows = $derived( let visibleShows = $derived(
@@ -40,6 +48,17 @@
: shows.filter((show) => shares.some((share) => share.showId === show.id)) : shows.filter((show) => shares.some((share) => share.showId === show.id))
); );
let dialog: HTMLDialogElement; 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') { function germanDate(value: string | null, fallback = 'Termin offen') {
return value return value
@@ -87,174 +106,225 @@
} }
</script> </script>
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}> {#if catalogPanel}
<AppIcon name="link" /> Lesefreigaben <section class="quiet-panel share-catalog-panel">
</button> <div class="panel-heading">
<dialog bind:this={dialog} class="share-management-dialog"> <div>
<section <p class="eyebrow">Gastzugänge</p>
class="quiet-panel share-management" <h2>Lesefreigaben</h2>
aria-labelledby={`share-title-${fixedShowId || 'all'}`} </div>
> <button
<div class="panel-heading"> class="compact-add-button share-dialog-trigger"
<div> type="button"
<p class="eyebrow">Gastzugänge</p> aria-label="Lesefreigaben"
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2> onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Freigaben verwalten
</button>
</div> </div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span> <p class="catalog-note">
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}> Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
<AppIcon name="xmark" /> </p>
</button> </section>
</div> {:else}
<p class="share-intro"> <button
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link class="secondary-button share-dialog-trigger"
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt. type="button"
</p> onclick={() => dialog.showModal()}
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div >
class="form-message error" <AppIcon name="link" /> Lesefreigaben
role="alert" </button>
> {/if}
{form.error} <dialog bind:this={dialog} class="share-management-dialog">
</div>{/if} <section class="share-management" aria-labelledby={`share-title-${fixedShowId || 'all'}`}>
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div <div class="panel-heading">
class="form-message success" <div>
role="status" <p class="eyebrow">Gastzugänge</p>
> <h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
{form.success} </div>
</div>{/if} <span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
{#if form?.shareUrl}<div class="form-message success share-result" role="status"> <button
<strong>Neuer Leselink</strong> class="dialog-close"
<!-- The server returns only a same-origin share path created by this action. --> type="button"
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> aria-label="Lesefreigaben schließen"
<a href={form.shareUrl}>{form.shareUrl}</a> onclick={() => dialog.close()}
</div>{/if} >
<AppIcon name="xmark" />
</button>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
{#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><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"> <form class="share-create-form" method="POST" action="?/createShare">
{#if fixedShowId} {#if fixedShowId}
<input type="hidden" name="showId" value={fixedShowId} /> <input type="hidden" name="showId" value={fixedShowId} />
{:else} {:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
</select></label
>
{/if}
<label <label
>Sendung<select name="showId" required> >Informationsumfang<select name="scope">
<option value="">Sendung wählen</option> <option value="rundown">Nur Ablauf</option>
{#each shows as show (show.id)}<option value={show.id} <option value="moderation">Ablauf und Moderation</option>
>{show.title} · {statusLabel(show.status)}</option <option value="research">Vollständig mit Recherche</option>
>{/each}
</select></label </select></label
> >
{/if} <label
<label >Optionales Passwort<input
>Informationsumfang<select name="scope"> name="password"
<option value="rundown">Nur Ablauf</option> type="password"
<option value="moderation">Ablauf und Moderation</option> minlength="12"
<option value="research">Vollständig mit Recherche</option> maxlength="256"
</select></label autocomplete="new-password"
> /></label
<label >
>Optionales Passwort<input <label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
name="password" <button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
type="password" >
minlength="12" </form>
maxlength="256"
autocomplete="new-password"
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
</form>
{#if visibleShows.length} {#if visibleShows.length}
<div class="share-show-list"> <div class="share-show-list">
{#each visibleShows as show (show.id)} {#each visibleShows as show (show.id)}
<article class="share-show-card"> <article class="share-show-card">
<header> <header>
<div> <div>
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a> <a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
<small>{germanDate(show.startsAtUtc)} Uhr</small> <small>{germanDate(show.startsAtUtc)} Uhr</small>
</div> </div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span> <span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
</header> </header>
<div class="share-list"> <div class="share-list">
{#each shares.filter((share) => share.showId === show.id) as share (share.id)} {#each shares.filter((share) => share.showId === show.id) as share (share.id)}
<details class="share-row"> <details class="share-row">
<summary> <summary>
<span class="share-row-icon"><AppIcon name="link" /></span> <span class="share-row-icon"><AppIcon name="link" /></span>
<span> <span>
<strong>{scopeLabel(share.scope)}</strong> <strong>{scopeLabel(share.scope)}</strong>
<small <small
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate( >{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
share.expiresAtUtc, share.expiresAtUtc,
'ohne Ablauf' 'ohne Ablauf'
)}</small )}</small
>
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<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"
onsubmit={(event) => {
if (
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
)
event.preventDefault();
}}
> >
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} /> <input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} /> <input type="hidden" name="showId" value={show.id} />
<label <button class="text-button danger-text" type="submit">Freigabe löschen</button
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
> >
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form> </form>
{/if} </div>
<form </details>
method="POST" {/each}
action="?/deleteShare" {#if !shares.some((share) => share.showId === show.id)}
onsubmit={(event) => { <p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
if ( {/if}
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.') </div>
) </article>
event.preventDefault(); {/each}
}} </div>
> {:else}
<input type="hidden" name="shareId" value={share.id} /> <p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
<input type="hidden" name="showId" value={show.id} /> {/if}
<button class="text-button danger-text" type="submit">Freigabe löschen</button> </section>
</form>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</dialog> </dialog>
+145 -68
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte'; import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte'; import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte';
import PresentationDisplay from './PresentationDisplay.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
let { let {
@@ -13,7 +14,8 @@
sectionId, sectionId,
sections, sections,
error, error,
enhanceCommand enhanceCommand,
open = false
}: { }: {
usage: PieceUsage; usage: PieceUsage;
revision: number; revision: number;
@@ -21,6 +23,7 @@
sections: { id: string; name: string }[]; sections: { id: string; name: string }[];
error?: string; error?: string;
enhanceCommand: SubmitFunction; enhanceCommand: SubmitFunction;
open?: boolean;
} = $props(); } = $props();
let formId = $derived(`usage-editor-form-${usage.id}`); let formId = $derived(`usage-editor-form-${usage.id}`);
@@ -30,6 +33,14 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) })) untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
); );
let dirty = $state(false); 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( let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
); );
@@ -38,12 +49,63 @@
return Number((seconds / 60).toFixed(2)); 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() { function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return; if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false; dirty = false;
editor.close(); 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 enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input); const finish = await enhanceCommand(input);
return async (result) => { return async (result) => {
@@ -125,6 +187,26 @@
<details class="piece-meta-details"> <details class="piece-meta-details">
<summary>Dauer und Einplanung</summary> <summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid"> <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 <label
>Dauer in Minuten<input >Dauer in Minuten<input
name="durationMinutes" name="durationMinutes"
@@ -212,20 +294,7 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label <p class="field-label">Präsentationsart</p>
>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}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung"> <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])} {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label <label
@@ -238,6 +307,25 @@
> >
{/each} {/each}
</div> </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 <input
type="hidden" type="hidden"
name="presentationSourceLabel" name="presentationSourceLabel"
@@ -253,32 +341,42 @@
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} /> <input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'} {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings"> <div class="presentation-settings">
<div class="presentation-source-summary wide-field"> <label class="wide-field"
<span>Ausgewählte Quelle</span> >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
<strong bind:value={selectedSourceId}
>{draft.presentationSourceLabel || onchange={presentationSourceChanged}
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
> >
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if} <option value="">Quelle auswählen</option>
</div> {#each draft.sources as source (source.id)}
<option value={source.id}>{source.label || source.url}</option>
{/each}
</select></label
>
<label <label
>Kartentitel<input >{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle} bind:value={draft.presentationTitle}
/></label /></label
> >
<label {#if draft.presentationMode === 'link'}
>Bild-URL<input <label
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl" name="presentationImageUrl"
type="url" value={draft.presentationImageUrl}
maxlength="2000" />
placeholder="https://…" {/if}
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field" <label class="wide-field"
>Anreißer<textarea >Anreißer<textarea
name="presentationDescription" name="presentationDescription"
@@ -298,18 +396,20 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if} {/if}
</div> </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> <legend>Hochgeladenes Präsentationsbild</legend>
<label {#if draft.presentationMode === 'text'}<label
><input ><input
type="radio" type="radio"
checked={!draft.presentationMediaId} checked={!draft.presentationMediaId}
onchange={() => { onchange={() => {
draft.presentationMediaId = null; draft.presentationMediaId = null;
dirty = true; dirty = true;
}} }}
/> Kein Bild</label /> Kein Bild</label
> >{/if}
<div class="media-grid"> <div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice" {#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input ><input
@@ -326,30 +426,7 @@
</div> </div>
</fieldset>{/if} </fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p> <PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
<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}
</aside> </aside>
</div> </div>
</section> </section>
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 18, migrations: 19,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+82 -1
View File
@@ -16,6 +16,7 @@ import {
moveOpenUsage, moveOpenUsage,
presentationChannelAccess, presentationChannelAccess,
presentUsage, presentUsage,
resumeUsage,
rotatePresentationChannelKey, rotatePresentationChannelKey,
selectUsage, selectUsage,
skipCurrent, skipCurrent,
@@ -59,6 +60,11 @@ describe('live mode', () => {
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'" "UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
) )
.run(); .run();
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 600,
elapsedSeconds: 100
});
completeCurrent( completeCurrent(
'show-october', 'show-october',
'timer-next-piece', 'timer-next-piece',
@@ -104,13 +110,54 @@ describe('live mode', () => {
.find((usage) => usage.id === 'usage-console')?.status .find((usage) => usage.id === 'usage-console')?.status
).toBe('done'); ).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({ expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console', id: 'usage-console',
status: 'current' 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', () => { it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october'); const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key); expect(issuePresentationAccess('show-october')).toBe(key);
@@ -239,6 +286,40 @@ describe('live mode', () => {
expect(media).toBeNull(); expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).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'); const replacementToken = issuePresentationAccess('show-october');
expect(replacementToken).toBe(token); expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe( expect(getPresentationView(token)?.content?.title).toBe(
+86 -23
View File
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) { function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database const row = database
.prepare( .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 JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_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'); if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
return row;
} }
function closeActiveRun(database: Database.Database, showId: string, now: string) { 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` FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
) )
.get(showId) as .get(showId) as
| { id: string; section_id: string; planned_duration_seconds: number } { id: string; section_id: string; planned_duration_seconds: number } | undefined;
| undefined;
if (active?.section_id === section.id) { if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) { if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now); closeSectionTimer(database, showId, now);
@@ -279,6 +279,29 @@ function syncSectionTimer(
} }
if (active) closeSectionTimer(database, showId, now); if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return; 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(); const timerId = randomUUID();
database database
.prepare( .prepare(
@@ -287,12 +310,14 @@ function syncSectionTimer(
VALUES (?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?)`
) )
.run(timerId, showId, section.id, section.explicit_duration_seconds, now); .run(timerId, showId, section.id, section.explicit_duration_seconds, now);
database if (showTimerRunning(database, showId)) {
.prepare( database
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) .prepare(
VALUES (?, ?, ?)` `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
) VALUES (?, ?, ?)`
.run(randomUUID(), timerId, now); )
.run(randomUUID(), timerId, now);
}
} }
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) { 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 state = ensureLiveState(database, showId, now);
const show = getShow(showId); const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND'); 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 durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({ const sections = show.sections.map((section) => ({
...section, ...section,
@@ -941,7 +969,8 @@ export function selectUsage(
(database, state, now) => { (database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED'); if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED'); 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 === usageId) return;
if (state.current_usage_id) { if (state.current_usage_id) {
closeActiveRun(database, showId, now); 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( export function presentUsage(
showId: string, showId: string,
usageId: string | null, usageId: string | null,
@@ -1165,10 +1224,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
} }
| undefined; | undefined;
if (!usage) return { revision: state.presentation_revision, content: null }; if (!usage) return { revision: state.presentation_revision, content: null };
const mode = usage.presentation_mode_snapshot;
return { return {
revision: state.presentation_revision, revision: state.presentation_revision,
content: { content: {
mode: usage.presentation_mode_snapshot, mode,
topics: ( topics: (
database database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') .prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
@@ -1185,7 +1245,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
: null, : null,
previewTitle: usage.presentation_title_meta_snapshot, previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_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, siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot, provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot, providerId: usage.presentation_provider_id_snapshot,
@@ -1195,21 +1255,23 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources: [], sources: [],
images: ( images: ['text', 'image'].includes(mode)
database ? (
.prepare( database
`SELECT id, alt_text FROM usage_media_snapshots .prepare(
WHERE usage_id = ? AND is_presentation = 1` `SELECT id, alt_text FROM usage_media_snapshots
) WHERE usage_id = ? AND is_presentation = 1`
.all(state.presentation_usage_id) as { id: string; alt_text: string }[] )
).map((media) => ({ id: media.id, altText: media.alt_text })) .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) { export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token); 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 { try {
return await fetchPresentationImage(view.content.imageUrl); return await fetchPresentationImage(view.content.imageUrl);
} catch { } catch {
@@ -1233,7 +1295,8 @@ export async function getPresentationMedia(
JOIN show_sections s ON s.id = u.show_section_id JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_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 .get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined; { storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
+11 -1
View File
@@ -13,6 +13,7 @@ import {
getSharedMedia, getSharedMedia,
getSharedShow, getSharedShow,
revokeShare, revokeShare,
replaceShareToken,
updateShare updateShare
} from './shares.js'; } from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js'; import { addPieceUsage, addShowSection, createShow } from './shows.js';
@@ -138,8 +139,17 @@ describe('show shares', () => {
expect(getSharedShow(shared.token).status).toBe('password'); expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20'); const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized'); 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(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 () => { 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 { export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor); requireBarkeeperActor(actor);
const result = getDatabase() const result = getDatabase()
+46
View File
@@ -13,6 +13,7 @@ import {
duplicatePieceUsage, duplicatePieceUsage,
getPieceUsageContexts, getPieceUsageContexts,
getShow, getShow,
listPreparationUsages,
listShowTemplates, listShowTemplates,
movePieceUsage, movePieceUsage,
moveShowSection, moveShowSection,
@@ -60,12 +61,57 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationImageUrl: edited.presentationImageUrl, presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName, presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId, presentationMediaId: edited.presentationMediaId,
preparationStatus: edited.preparationStatus,
preparationEffort: edited.preparationEffort,
ownNotes: edited.ownNotes, ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds durationSeconds: edited.durationSeconds
}; };
} }
describe('show builder data layer', () => { 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', () => { it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null }); const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, { 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 type Database from 'better-sqlite3';
import { createPiece } from './content.js'; import { createPiece } from './content.js';
import { getDatabase } from './database.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 SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] 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 ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>; export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number]; export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type UsagePreparationStatus = 'preparing' | 'ready';
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type ShowListItem = { export type ShowListItem = {
id: string; id: string;
@@ -21,6 +23,17 @@ export type ShowListItem = {
sectionCount: number; 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 = { export type ShowTemplateSection = {
id: string; id: string;
name: string; name: string;
@@ -42,6 +55,8 @@ export type PieceUsage = {
originalPieceId: string | null; originalPieceId: string | null;
position: number; position: number;
status: UsageStatus; status: UsageStatus;
preparationStatus: UsagePreparationStatus;
preparationEffort: PreparationEffort;
durationSeconds: number; durationSeconds: number;
title: string; title: string;
outputTitle: string; outputTitle: string;
@@ -59,7 +74,7 @@ export type PieceUsage = {
presentationDescription: string; presentationDescription: string;
presentationImageUrl: string; presentationImageUrl: string;
presentationSiteName: string; presentationSiteName: string;
presentationProvider: string; presentationProvider: VideoProvider | '';
presentationProviderId: string; presentationProviderId: string;
ownNotes: string; ownNotes: string;
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[]; sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
@@ -175,6 +190,8 @@ export type PieceUsageUpdate = {
presentationSiteName: string; presentationSiteName: string;
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
preparationStatus?: UsagePreparationStatus;
preparationEffort?: PreparationEffort;
tools?: { id: string; workUrl: string; note: string }[]; tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null; presentationMediaId?: string | null;
}; };
@@ -220,6 +237,8 @@ type UsageRow = {
original_piece_id: string | null; original_piece_id: string | null;
position: number; position: number;
status: UsageStatus; status: UsageStatus;
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
duration_seconds: number; duration_seconds: number;
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string; output_title_snapshot: string;
@@ -238,7 +257,7 @@ type UsageRow = {
presentation_description_snapshot: string; presentation_description_snapshot: string;
presentation_image_url_snapshot: string; presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string; presentation_site_name_snapshot: string;
presentation_provider_snapshot: string; presentation_provider_snapshot: VideoProvider | '';
presentation_provider_id_snapshot: string; presentation_provider_id_snapshot: string;
own_notes: string; own_notes: string;
}; };
@@ -459,6 +478,8 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
originalPieceId: row.original_piece_id, originalPieceId: row.original_piece_id,
position: row.position, position: row.position,
status: row.status, status: row.status,
preparationStatus: row.preparation_status_snapshot,
preparationEffort: row.preparation_effort_snapshot,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
title: row.title_snapshot, title: row.title_snapshot,
outputTitle: row.output_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[] { export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase(); const database = getDatabase();
const templates = database const templates = database
@@ -821,6 +886,7 @@ export function getShow(id: string): Show | null {
const usageRows = database const usageRows = database
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `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.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, 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, 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_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
for (const section of sections) { for (const section of sections) {
const sectionId = randomUUID(); const sectionId = randomUUID();
@@ -1127,6 +1194,8 @@ export function duplicateCompletedShow(
usage.presentation_provider_snapshot, usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot, usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot, usage.presentation_image_only_snapshot,
usage.preparation_status_snapshot,
usage.preparation_effort_snapshot,
usage.own_notes, usage.own_notes,
usage.is_reserve usage.is_reserve
); );
@@ -1306,7 +1375,8 @@ export function addPieceUsage(
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url, 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 FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?` AND ps.position = p.presentation_source_position WHERE p.id = ?`
) )
@@ -1332,6 +1402,7 @@ export function addPieceUsage(
presentation_provider: string; presentation_provider: string;
presentation_provider_id: string; presentation_provider_id: string;
status: string; status: string;
preparation_effort: PreparationEffort;
} }
| undefined; | undefined;
if (!piece) throw new Error('NOT_FOUND'); if (!piece) throw new Error('NOT_FOUND');
@@ -1342,7 +1413,7 @@ export function addPieceUsage(
) { ) {
throw new Error('NOT_FOUND'); 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 = ( const ids = (
database database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') .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_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -1387,6 +1459,8 @@ export function addPieceUsage(
piece.presentation_site_name, piece.presentation_site_name,
piece.presentation_provider, piece.presentation_provider,
piece.presentation_provider_id, piece.presentation_provider_id,
piece.status,
piece.preparation_effort,
input.sectionId === null ? 1 : 0 input.sectionId === null ? 1 : 0
); );
snapshotPieceRelations(database, id, input.pieceId); snapshotPieceRelations(database, id, input.pieceId);
@@ -1468,6 +1542,7 @@ export function duplicatePieceUsage(
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?` own_notes, is_reserve FROM piece_usages WHERE id = ?`
) )
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & { .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_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -1518,6 +1594,8 @@ export function duplicatePieceUsage(
source.presentation_site_name_snapshot, source.presentation_site_name_snapshot,
source.presentation_provider_snapshot, source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot, source.presentation_provider_id_snapshot,
source.preparation_status_snapshot,
source.preparation_effort_snapshot,
source.own_notes, source.own_notes,
source.is_reserve source.is_reserve
); );
@@ -1546,7 +1624,8 @@ export function refreshPieceUsageFromOriginal(
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url, 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 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 LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position AND ps.position = p.presentation_source_position
@@ -1573,9 +1652,12 @@ export function refreshPieceUsageFromOriginal(
presentation_site_name: string; presentation_site_name: string;
presentation_provider: string; presentation_provider: string;
presentation_provider_id: string; presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
} }
| undefined; | undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND'); if (!source) throw new Error('ORIGINAL_NOT_FOUND');
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, `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_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?, presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_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( .run(
source.title, source.title,
@@ -1608,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
source.presentation_site_name, source.presentation_site_name,
source.presentation_provider, source.presentation_provider,
source.presentation_provider_id, source.presentation_provider_id,
source.status,
source.preparation_effort,
usageId usageId
); );
snapshotPieceRelations( snapshotPieceRelations(
@@ -1632,6 +1717,18 @@ export function updatePieceUsage(
const title = cleanText(input.title, 200, 'title', true); const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title'); const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!; 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 = { const values = {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'), research: cleanText(input.research, 50_000, 'research'),
@@ -1642,7 +1739,7 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'), presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes') ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
}; };
const presentation = validPresentation( const submittedPresentation = validPresentation(
input.presentationMode, input.presentationMode,
input.presentationSourceLabel, input.presentationSourceLabel,
input.presentationSourceUrl, input.presentationSourceUrl,
@@ -1656,6 +1753,57 @@ export function updatePieceUsage(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId); 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 database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `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_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?, presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_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( .run(
title, title,
@@ -1689,6 +1838,10 @@ export function updatePieceUsage(
presentation.providerId, presentation.providerId,
values.ownNotes, values.ownNotes,
duration, duration,
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
input.preparationEffort === undefined
? currentPreparation.preparation_effort_snapshot
: input.preparationEffort,
usageId usageId
); );
if (input.tools) { if (input.tools) {
@@ -1721,23 +1874,15 @@ export function updatePieceUsage(
} }
} }
if (input.presentationMediaId !== undefined) { 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 database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?') .prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId); .run(usageId);
if (input.presentationMediaId !== null) { if (selectedMediaId !== null) {
database database
.prepare( .prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?' 'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
) )
.run(input.presentationMediaId, usageId); .run(selectedMediaId, usageId);
} }
} }
return result; return result;
+117 -81
View File
@@ -77,14 +77,19 @@
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId : draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${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( let presentationPreviewImage = $derived(
selectedMedia selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? draft.presentationMode === 'text' ? draft.presentationMode === 'text'
? '/media' ? '/media'
: `/media/${selectedMedia.id}` : `/media/${selectedMedia.id}`
: sourcePreviewImage : draft.presentationMode === 'link'
? sourcePreviewImage
: ''
); );
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
@@ -116,8 +121,8 @@
if ( if (
control instanceof HTMLInputElement && control instanceof HTMLInputElement &&
control.name === 'presentationMode' && control.name === 'presentationMode' &&
control.value !== 'text' && ((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
draft.presentationSourcePosition === null (control.value === 'image' && !draft.presentationMediaId))
) { ) {
return; return;
} }
@@ -348,10 +353,11 @@
/></label /></label
> >
<label <label
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort} >Vorbereitungsaufwand<select
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option name="preparationEffort"
value="medium">Mittel</option bind:value={draft.preparationEffort}
><option value="high">Hoch</option></select ><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option></select
></label ></label
> >
<label <label
@@ -533,21 +539,7 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label <p class="field-label">Präsentationsart</p>
>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}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung"> <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 {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input ><input
@@ -558,60 +550,103 @@
/>{mode[1]}</label />{mode[1]}</label
>{/each} >{/each}
</div> </div>
<div class="presentation-settings"> {#if draft.presentationMode === 'text'}
<label <label
>Quelle<select >Öffentliche Überschrift<input
name="presentationSourcePosition" name="outputTitle"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
><option value={null}>Quelle auswählen</option
>{#each sources as source, index (index)}{#if source.url}<option value={index}
>{source.label || source.url}</option
>{/if}{/each}</select
></label
>
<label
>Kartentitel<input
name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optional, sonst interner Titel"
bind:value={draft.presentationTitle} bind:value={draft.outputTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label /></label
> >
<label <MarkdownEditorField
>Bild-URL<input name="presentationText"
name="presentationImageUrl" label="Öffentlicher Vorspann"
type="url" placeholder="Text für die Publikumsausgabe"
maxlength="2000" rows={8}
placeholder="https://…" bind:value={draft.presentationText}
bind:value={draft.presentationImageUrl} />
oninput={() => (draft.presentationImageManual = true)} {:else}
/></label <input type="hidden" name="outputTitle" value={draft.outputTitle} />
> <input type="hidden" name="presentationText" value={draft.presentationText} />
<label class="wide-field" {/if}
>Anreißer<textarea {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
name="presentationDescription" <div class="presentation-settings">
rows="3" <label
maxlength="1000" >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
placeholder="Kurzer Text zur Vorschau" name="presentationSourcePosition"
bind:value={draft.presentationDescription} bind:value={draft.presentationSourcePosition}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label onchange={presentationSourceChanged}
> ><option value={null}>Quelle auswählen</option
<div class="presentation-preview-actions wide-field"> >{#each sources as source, index (index)}{#if source.url}<option value={index}
<button >{source.label || source.url}</option
class="secondary-button" >{/if}{/each}</select
type="button" ></label
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}</small
> >
<label
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
{#if draft.presentationMode === 'link'}
<label
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
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"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}</small
>
</div>
</div> </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> </div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay <PresentationDisplay
@@ -638,6 +673,7 @@
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} /> <input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} /> <input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
<input <input
type="hidden" type="hidden"
name="presentationMetadataFetchedAtUtc" name="presentationMetadataFetchedAtUtc"
@@ -646,7 +682,11 @@
</section> </section>
</form> </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 class="piece-subsection-heading">
<div> <div>
<p class="eyebrow">Hochgeladene Bilder</p> <p class="eyebrow">Hochgeladene Bilder</p>
@@ -654,12 +694,10 @@
</div> </div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small> <small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div> </div>
{#if data.piece.media.length}<label class="media-none" {#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
><input ><input
form="piece-editor-form"
type="radio" type="radio"
name="presentationMediaId" name="presentationMediaChoice"
value=""
checked={!draft.presentationMediaId} checked={!draft.presentationMediaId}
onchange={() => { onchange={() => {
draft.presentationMediaId = null; draft.presentationMediaId = null;
@@ -668,13 +706,11 @@
/> Kein hochgeladenes Bild</label /> Kein hochgeladenes Bild</label
> >
<div class="media-grid"> <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" <label class="media-presentation-choice"
><input ><input
form="piece-editor-form"
type="radio" type="radio"
name="presentationMediaId" name="presentationMediaChoice"
value={media.id}
checked={draft.presentationMediaId === media.id} checked={draft.presentationMediaId === media.id}
onchange={() => { onchange={() => {
draft.presentationMediaId = media.id; draft.presentationMediaId = media.id;
@@ -14,10 +14,12 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div> <div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay <PresentationDisplay
content={data.content} content={data.content}
imageSrc={data.content.images.length imageSrc={data.content.mode === 'text' && data.content.images.length
? data.content.mode === 'text' ? '/media'
? '/media' : data.content.mode === 'image' && data.content.images.length
: `/media/${data.content.images[0].id}` ? `/media/${data.content.images[0].id}`
: `/fundus/${data.pieceId}/praesentation/preview-image`} : data.content.mode === 'link' && data.content.imageUrl
? `/fundus/${data.pieceId}/praesentation/preview-image`
: ''}
/> />
</main> </main>
@@ -7,7 +7,9 @@ import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => { export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user); requireUser(locals.user);
const piece = getPiece(params.id); 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 { try {
const image = await fetchPresentationImage(piece.presentationImageUrl); const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), { return new Response(new Uint8Array(image.data), {
+54 -7
View File
@@ -16,6 +16,7 @@ import {
rotatePresentationChannelKey, rotatePresentationChannelKey,
resetSectionTimer, resetSectionTimer,
resetTotalTimer, resetTotalTimer,
resumeUsage,
resumeEndedLive, resumeEndedLive,
resumeLive, resumeLive,
selectUsage, selectUsage,
@@ -25,7 +26,13 @@ import {
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.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'; import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) { function text(data: FormData, name: string) {
@@ -99,10 +106,16 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
try { try {
const share = await createShare(actor, { 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') 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) { } catch (cause) {
const problem = actionError(cause); const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem }); return fail(problem.status, { intent: 'createShare', ...problem });
@@ -112,16 +125,38 @@ export const actions = {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
try { try {
await updateShare(actor, text(data, 'shareId'), { await updateShare(
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'), actor,
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') text(data, 'shareId'),
}, text(data, 'showId')); {
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
},
text(data, 'showId')
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' }; return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) { } catch (cause) {
const problem = actionError(cause); const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem }); 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 }) => { deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); 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 }) => { present: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+76 -36
View File
@@ -591,7 +591,12 @@
</section> </section>
<section> <section>
<strong>Ausgabekanal</strong> <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}> <form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button> <button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
@@ -814,9 +819,14 @@
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)} {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if} </strong>{/if}
</div> </div>
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}> {#if viewedSection?.twitchTags.length}<button
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')} class="text-button live-twitch-tags"
</button>{/if} type="button"
onclick={copyTwitchTags}
>
<AppIcon name="copy" />
{viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro"> {#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span> <span>Einleitung</span>
@@ -1000,6 +1010,22 @@
> >
</button> </button>
<div class="live-run-actions"> <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"> {#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`} <summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary ><AppIcon name="ellipsis" /></summary
@@ -1176,38 +1202,52 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </form>
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}> {#if (viewedUsage.status === 'done' || viewedUsage.status === 'skipped') && !viewingLiveCurrent}
<input type="hidden" name="showId" value={data.live.show.id} /> <form method="POST" action={liveAction('resumeUsage')} use:enhance={enhanceCommand}>
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="usageId" value={viewedUsage.id} />
<button <input type="hidden" name="revision" value={data.live.liveRevision} />
class="secondary-button" <input type="hidden" name="commandId" value={data.commandId} />
type="submit" <button
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)} class="primary-button"
title={!viewingLiveCurrent type="submit"
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren' disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}>Wiederaufnehmen</button
: undefined}>Überspringen</button >
> </form>
</form> {:else}
<form method="POST" action={liveAction('complete')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button <button
class="primary-button" class="secondary-button"
type="submit" type="submit"
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)} disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
title={!viewingLiveCurrent title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren' ? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined} : undefined}>Überspringen</button
> >
{data.live.next </form>
? 'Erledigen & weiter' <form method="POST" action={liveAction('complete')} use:enhance={enhanceCommand}>
: hasOtherOpenUsages <input type="hidden" name="showId" value={data.live.show.id} />
? 'Erledigen' <input type="hidden" name="revision" value={data.live.liveRevision} />
: 'Erledigen & Sendungsende'} <input type="hidden" name="commandId" value={data.commandId} />
</button> <button
</form> class="primary-button"
type="submit"
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}
>
{data.live.next
? 'Erledigen & weiter'
: hasOtherOpenUsages
? 'Erledigen'
: 'Erledigen & Sendungsende'}
</button>
</form>
{/if}
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong {:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if} >{/if}
</div> </div>
+7 -3
View File
@@ -2,8 +2,12 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js'; import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js'; import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js'; import {
import { listPieces } from '#lib/server/content.js'; createShow,
listPreparationUsages,
listShows,
listShowTemplates
} from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js'; import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
@@ -13,7 +17,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth); const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return { return {
shows: listShows(['draft', 'ready', 'live']), shows: listShows(['draft', 'ready', 'live']),
preparationPieces: listPieces({ statuses: ['preparing'] }), preparationUsages: listPreparationUsages(),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
selectedMonth, selectedMonth,
+38 -6
View File
@@ -53,6 +53,10 @@
}).format(new Date(`${value}T12:00:00Z`)); }).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) { function statusLabel(status: string) {
if (status === 'ready') return 'Bereit'; if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf'; if (status === 'draft') return 'Entwurf';
@@ -125,13 +129,41 @@
</form> </form>
</details> </details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title"> {#if data.preparationUsages.length}<section
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header> 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"> <div class="preparation-list">
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}> {#each data.preparationUsages as usage (usage.usageId)}
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span> <!-- The helper starts from a route resolved against the configured base path. -->
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if} <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
</a>{/each} <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> </div>
</section>{/if} </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 { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js'; import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.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 { berlinLocalToUtc } from '#lib/server/time.js';
import { import {
addPieceUsage, addPieceUsage,
@@ -123,7 +129,7 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
}; };
} }
const pieces = listPieces({ const pieces = listPieces({
statuses: ['ready'], statuses: ['preparing', 'ready'],
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '' categoryId: url.searchParams.get('category') ?? ''
}); });
@@ -192,6 +198,20 @@ export const actions = {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) }); 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 }) => { deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -318,6 +338,9 @@ export const actions = {
presentationDescription: text(data, 'presentationDescription'), presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'), presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'), presentationSiteName: text(data, 'presentationSiteName'),
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
preparationEffort: (text(data, 'preparationEffort') ||
null) as PieceUsageUpdate['preparationEffort'],
presentationMediaId: text(data, 'presentationMediaId') || null, presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({ tools: toolIds.map((id, index) => ({
id, id,
+8 -2
View File
@@ -431,7 +431,7 @@
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Quelle</p> <p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2> <h2 id="source-title">Planbarer Fundus</h2>
</div> </div>
<span>{visiblePieces.length}</span> <span>{visiblePieces.length}</span>
</header> </header>
@@ -661,6 +661,7 @@
{#if section.usages.length}<div class="usage-list"> {#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)} {#each section.usages as usage, usageIndex (usage.id)}
<div <div
id={`usage-${usage.id}`}
class="usage-drag-slot" class="usage-drag-slot"
role="presentation" role="presentation"
ondragover={(event) => { ondragover={(event) => {
@@ -750,6 +751,7 @@
</form> </form>
<UsageEditorDialog <UsageEditorDialog
{usage} {usage}
open={page.url.searchParams.get('usage') === usage.id}
revision={data.show.revision} revision={data.show.revision}
sectionId={section.id} sectionId={section.id}
sections={data.show.sections} sections={data.show.sections}
@@ -893,7 +895,11 @@
placeholder="optional, Sekunden" placeholder="optional, Sekunden"
/></label /></label
><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> ><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form> </form>
+21 -1
View File
@@ -8,7 +8,13 @@ import {
type TaxonomyKind type TaxonomyKind
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.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 { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js'; import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.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) }); 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 }) => { deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+77 -52
View File
@@ -32,7 +32,7 @@
> >
{form.success} {form.success}
</div>{/if} </div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} /> <h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-panel"> <section class="quiet-panel integration-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
@@ -45,40 +45,43 @@
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang. Eingang.
</p> </p>
<form class="integration-form" method="POST" action="?/saveFeed"> <details class="integration-create">
<label>Name<input name="name" required maxlength="120" /></label> <summary class="compact-add-button"><AppIcon name="plus" /> Feed hinzufügen</summary>
<label <form class="integration-form" method="POST" action="?/saveFeed">
>Feed-URL<input <label>Name<input name="name" required maxlength="120" /></label>
name="url" <label
type="url" >Feed-URL<input
required name="url"
maxlength="2000" type="url"
placeholder="https://example.org/feed.xml" required
/></label maxlength="2000"
> placeholder="https://example.org/feed.xml"
<label /></label
>Intervall in Minuten<input >
name="refreshIntervalMinutes" <label
type="number" >Intervall in Minuten<input
min="15" name="refreshIntervalMinutes"
max="10080" type="number"
value="60" min="15"
required max="10080"
/></label value="60"
> required
<label /></label
>Standardkategorie<select name="defaultCategoryId" >
><option value="">Keine</option <label
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id} >Standardkategorie<select name="defaultCategoryId"
>{category.name}</option ><option value="">Keine</option
>{/each}</select >{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
></label >{category.name}</option
> >{/each}</select
<label class="check-row" ></label
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label >
> <label class="check-row"
<button class="primary-button" type="submit">Feed anlegen</button> ><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
</form> >
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
</details>
<div class="integration-list"> <div class="integration-list">
{#each data.feeds as feed (feed.id)} {#each data.feeds as feed (feed.id)}
<details> <details>
@@ -169,19 +172,27 @@
{#if form?.technicalToken}<div class="token-reveal" role="status"> {#if form?.technicalToken}<div class="token-reveal" role="status">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code> <strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if} </div>{/if}
<form class="integration-form" method="POST" action="?/createTechnicalAccess"> <details class="integration-create">
<label <summary class="compact-add-button"><AppIcon name="plus" /> Zugang hinzufügen</summary>
>Name<input name="name" required maxlength="120" placeholder="Redaktionsassistenz" /></label <form class="integration-form" method="POST" action="?/createTechnicalAccess">
> <label
<label >Name<input
>Berechtigung<select name="permissions" name="name"
><option value="read">Nur lesen</option><option value="read_write" required
>Lesen und schreiben</option maxlength="120"
></select placeholder="Redaktionsassistenz"
></label /></label
> >
<button class="primary-button" type="submit">Zugang anlegen</button> <label
</form> >Berechtigung<select name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
</details>
<div class="integration-list"> <div class="integration-list">
{#each data.technicalAccesses as access (access.id)} {#each data.technicalAccesses as access (access.id)}
<div class="access-row"> <div class="access-row">
@@ -300,7 +311,8 @@
</section> </section>
</div> </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"> <section class="quiet-panel tool-management">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
@@ -456,6 +468,17 @@
gap: 0.8rem; gap: 0.8rem;
align-items: end; 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) { .integration-form label:not(.check-row) {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
@@ -472,8 +495,10 @@
} }
.integration-list details, .integration-list details,
.access-row { .access-row {
border-top: 1px solid var(--color-border); padding: 0.75rem 0.8rem;
padding-top: 0.8rem; border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
} }
.integration-list summary, .integration-list summary,
.access-row { .access-row {
@@ -488,7 +513,7 @@
gap: 0.2rem; gap: 0.2rem;
} }
.integration-list small { .integration-list small {
color: var(--color-text-muted); color: var(--text-muted);
} }
.row-actions { .row-actions {
display: flex; 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(); 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 }) => { test.beforeAll(async ({ browser }) => {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('/'); await page.goto('/');
@@ -665,7 +682,6 @@ test('show overview switches persistently between list, calendar and mobile agen
const octoberDay = page const octoberDay = page
.locator('.show-calendar-day') .locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' }); .filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin'); await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2); await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00'); 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) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
@@ -1106,6 +1159,7 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click(); await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover'); const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible(); await expect(popover).toBeVisible();
await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused();
const popoverBox = await popover.boundingBox(); const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0); expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width); 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.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click(); 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'); await page.goto('/sendungen/show-october');
const plannedConsole = page const plannedConsole = page
.locator('.editable-usage') .locator('.editable-usage')
@@ -1414,8 +1476,11 @@ test('live control persists state and exposes only explicit presentation data',
.locator('.live-run-item.current') .locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) .filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled(); await expect(
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled(); 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( await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible(); ).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('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible(); await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0); 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'); 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(); await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management'); 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('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation'); await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort'); 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_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0); await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible(); 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 guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage); await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true }); 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); expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung'); await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' }); const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible(); await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first(); 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 openEditorialField(page, 'Präsentationstext');
await page 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**'); .fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); 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('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check(); 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 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup'); 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.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); 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('Kartentitel').fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); 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 expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); 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' }) preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible(); ).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).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({ await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'), path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true fullPage: true