Release 0.5.0

This commit is contained in:
hnzio committed 2026-10-22 00:28:49 +02:00
1 parent 4042fe6a64
commit 5a9d7735d4
26 files changed
+1855 -113

No files matched your search

+359 -66
View File
@@ -1,96 +1,389 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock } from '#lib/format.js';
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { onMount, untrack } from 'svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props();
let { data, form }: PageProps = $props();
let promptSize = $state(1.55);
let showRail = $state(true);
let showTimes = $state(true);
let popupMessage = $state('');
let now = $state(Date.now());
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let outputWindow: Window | null = null;
let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
$effect(() => {
const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId) {
currentId = nextCurrentId;
loadedAt = Date.now();
}
});
onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`;
try {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
if (typeof settings.scrollY === 'number')
requestAnimationFrame(() => scrollTo(0, settings.scrollY));
} catch {
localStorage.removeItem(key);
}
const interval = setInterval(() => (now = Date.now()), 1000);
const persistScroll = () => persistSettings();
addEventListener('scroll', persistScroll, { passive: true });
return () => {
clearInterval(interval);
removeEventListener('scroll', persistScroll);
};
});
function persistSettings() {
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({ promptSize, showRail, showTimes, scrollY })
);
}
function changePromptSize(delta: number) {
promptSize = Math.min(2.4, Math.max(1.1, promptSize + delta));
persistSettings();
}
function toggleRail() {
showRail = !showRail;
persistSettings();
}
function toggleTimes() {
showTimes = !showTimes;
persistSettings();
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) {
command.value = crypto.randomUUID();
formData.set('commandId', command.value);
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ update }) => {
await update({ reset: false, invalidateAll: true });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
const enhanceOutput: SubmitFunction = ({ submitter }) => {
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') {
const response = result.data as { outputUrl: string };
if (outputWindow && !outputWindow.closed) {
outputWindow.location.href = response.outputUrl;
popupMessage = 'Präsentationsfenster verbunden.';
} else {
popupMessage = `Präsentationsfenster konnte nicht geöffnet werden: ${response.outputUrl}`;
}
} else {
outputWindow?.close();
}
await update({ reset: false, invalidateAll: false });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
</script>
<svelte:head><title>Live-Vorschau | Sendewerk</title></svelte:head>
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
<div class="page-wrap live-page">
<div class="page-wrap live-page live-controller">
<header class="live-header">
<div>
<p class="eyebrow"><span class="live-pulse"></span> Live-Modus · Vorschau</p>
<h1>{data.show.title}</h1>
<p class="eyebrow"><span class="live-pulse"></span> Live-Steuerung</p>
<h1>{data.live.show.title}</h1>
<p class="live-state-line">
{data.live.startedAtUtc
? 'Live-Stand wird automatisch gespeichert.'
: 'Noch nicht gestartet.'}
</p>
</div>
<div class="live-header-actions">
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => (promptSize = Math.max(1.15, promptSize - 0.1))}
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => (promptSize = Math.min(2.1, promptSize + 0.1))}
aria-label="Schrift vergrößern">A+</button
<button type="button" onclick={() => changePromptSize(0.1)} aria-label="Schrift vergrößern"
>A+</button
>
</div>
<a href={resolve('/sendungen/[id]', { id: data.show.id })}>Planung schließen</a>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}
>{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}</button
>
<button type="button" class="text-button" aria-pressed={!showRail} onclick={toggleRail}
>{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}</button
>
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}>Zur Planung</a>
</div>
</header>
<PreviewNotice area="Live-Modus" />
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
<div class="live-grid">
{#if data.current}<section class="live-current" style={`--prompt-size: ${promptSize}rem`}>
<section class="live-toolbar" aria-label="Live-Sitzung">
<form method="POST" action="?/start" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
</button>
</form>
<form method="POST" action="?/createOutput" use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Präsentationsfenster öffnen</button>
</form>
<div class="live-session-summary">
<strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span>
</div>
</section>
<div class:without-rail={!showRail} class="live-grid">
{#if data.live.current}<main
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
>
<div class="live-kicker">
<span>Aktuell · {data.current.section}</span><strong
>{formatClock(data.current.durationSeconds)}</strong
>
</div>
<h2>{data.current.title}</h2>
<div class="prompter-copy">{data.current.intro}</div>
<div class="moderation-card">
<span>Moderationsstichpunkte</span>
<p>{data.current.moderation}</p>
<span>Aktuell · {data.live.current.sectionName}</span>
{#if showTimes}<strong
>{formatClock(currentActualSeconds)} / {formatClock(
data.live.current.durationSeconds
)}</strong
>{/if}
</div>
<h2 id="current-title">{data.live.current.title}</h2>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
{#if data.live.current.moderationHtml}<section class="moderation-card">
<span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
</section>{/if}
<details class="research-details">
<summary>Recherche öffnen</summary>
<p>
Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim
Öffnen die Leseposition.
</p>
<summary>Manuelle Recherche öffnen</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
</details>
<div class="transition-card">
<span>Überleitung</span>
<p>{data.current.transition}</p>
</div>
</section>{:else}<section class="live-current live-empty">
<p class="eyebrow">Noch kein Piece eingeplant</p>
<h2>Diese Sendung hat noch keinen moderierbaren Inhalt.</h2>
<p>In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.</p>
</section>{/if}
{#if data.live.current.transitionHtml}<section class="transition-card">
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
</section>{/if}
<aside class="live-rail">
<div class="next-card">
<span>Als Nächstes</span>
{#if data.next}
<h3>{data.next.title}</h3>
<p>{data.next.section} · {formatClock(data.next.durationSeconds)}</p>
{:else}
<h3>Sendungsende</h3>
<p>Kein weiteres Stück eingeplant.</p>
{/if}
</div>
<div class="live-progress">
<header><span>Ablauf</span><strong>2 / 4</strong></header>
<div class="progress-track"><span></span></div>
{#each data.show.sections as section, index (section.id)}
<div class:active={index === 1} class="rail-section">
<span>{String(index + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong><small>{formatClock(section.durationSeconds)}</small>
<div class="live-current-actions">
<form method="POST" action="?/back" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zurück</button>
</form>
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
</form>
<form method="POST" action="?/skip" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Überspringen</button>
</form>
<form method="POST" action="?/complete" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">Erledigen und weiter</button>
</form>
</div>
</main>{:else}<main class="live-current live-empty">
<p class="eyebrow">Kein aktives Piece</p>
<h2>{data.live.totalCount ? 'Live-Stand bereit' : 'Noch kein Piece eingeplant'}</h2>
<p>
{data.live.totalCount
? 'Sendung starten oder einen abgeschlossenen Schritt zurückgehen.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
</p>
{#if data.live.completedCount}<form
method="POST"
action="?/back"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zum letzten Piece zurück</button>
</form>{/if}
</main>{/if}
{#if showRail}<aside class="live-rail">
<section class="next-card">
<span>Als Nächstes</span>
{#if data.live.next}
<h3>{data.live.next.title}</h3>
<p>
{data.live.next.sectionName}{#if showTimes}
· {formatClock(data.live.next.durationSeconds)}{/if}
</p>
{:else}
<h3>Sendungsende</h3>
<p>Kein weiteres offenes Piece eingeplant.</p>
{/if}
</section>
<section class="live-progress">
<header>
<span>Ablauf</span><strong>{data.live.completedCount} / {data.live.totalCount}</strong>
</header>
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
{#each data.live.sections as section, sectionIndex (section.id)}
<div class:active={section.id === data.live.current?.sectionId} class="rail-section">
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong>
{#if showTimes}<small>{formatDuration(section.durationSeconds)}</small>{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="readonly-actions" aria-label="Spätere Live-Aktionen">
<span>Zurück</span><strong>In Etappe 5 steuerbar</strong><span>Erledigen</span>
</div>
</aside>
{#each section.usages as usage, usageIndex (usage.id)}
<div class:current={usage.id === data.live.current?.id} class="live-run-item">
<div>
<strong>{usage.title}</strong><small>{usage.status}</small>
</div>
<div class="live-run-actions">
{#if usage.status === 'open'}
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex - 1} />
<button
type="submit"
disabled={usageIndex === 0 ||
section.usages[usageIndex - 1]?.status !== 'open'}
aria-label={`${usage.title} nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex + 1} />
<button
type="submit"
disabled={usageIndex === section.usages.length - 1 ||
section.usages[usageIndex + 1]?.status !== 'open'}
aria-label={`${usage.title} nach unten`}>↓</button
>
</form>
{/if}
<form method="POST" action="?/present" 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 type="submit" aria-label={`${usage.title} präsentieren`}>↗</button>
</form>
</div>
</div>
{/each}
{/each}
</section>
<section class="live-reserve">
<header><span>Reserve</span><strong>{data.live.reserve.length}</strong></header>
{#each data.live.reserve as usage, reserveIndex (usage.id)}
<div class="live-reserve-item">
<div>
<strong>{usage.title}</strong>{#if showTimes}<small
>{formatClock(usage.durationSeconds)}</small
>{/if}
</div>
<div class="live-run-actions">
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex - 1} />
<button
type="submit"
disabled={reserveIndex === 0}
aria-label={`${usage.title} in Reserve nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex + 1} />
<button
type="submit"
disabled={reserveIndex === data.live.reserve.length - 1}
aria-label={`${usage.title} in Reserve nach unten`}>↓</button
>
</form>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action="?/moveUsage"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input
type="hidden"
name="sectionId"
value={data.live.current?.sectionId ?? data.live.sections[0]?.id}
/>
<input
type="hidden"
name="position"
value={(data.live.current?.position ?? -1) + 1}
/>
<button class="text-button" type="submit">Als Nächstes</button>
</form>{/if}
</div>
</div>
{/each}
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
</section>
</aside>{/if}
</div>
</div>