Release 0.5.1

This commit is contained in:
hnzio committed 2026-10-22 07:56:25 +02:00
1 parent 5a9d7735d4
commit fb24d171ae
36 files changed
+5099 -263

No files matched your search

+475 -7
View File
@@ -1345,6 +1345,111 @@ a {
font-size: 0.85rem;
}
.output-link-card {
grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.85fr);
align-items: center;
gap: clamp(2rem, 5vw, 6rem);
}
.output-link-image {
display: grid;
min-height: 20rem;
place-items: center;
border-radius: 1.4rem;
background: rgb(255 255 255 / 5%);
overflow: hidden;
}
.output-link-image img {
width: 100%;
max-height: 68vh;
object-fit: contain;
}
.output-link-copy h1,
.output-video h1 {
max-width: 24ch;
font-size: clamp(2rem, 4.5vw, 5rem);
line-height: 1;
}
.output-link-copy > p:not(.output-kicker) {
max-width: 38ch;
color: rgb(255 247 232 / 78%);
font-size: clamp(1.1rem, 2vw, 1.65rem);
line-height: 1.45;
}
.output-link-copy a,
.output-fallback a,
.output-video-source a {
display: inline-flex;
margin-top: 1rem;
color: #e7b957;
font-weight: 740;
}
.output-video {
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch;
gap: 1rem;
}
.output-video header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1.5rem;
}
.output-video header h1 {
margin: 0;
}
.output-player {
display: grid;
min-height: 0;
place-items: center;
}
.output-player iframe {
width: min(100%, calc((100vh - 13rem) * 16 / 9));
aspect-ratio: 16 / 9;
border: 0;
border-radius: 1rem;
background: #000;
}
.output-video.portrait .output-player iframe {
width: min(100%, calc((100vh - 13rem) * 9 / 16));
aspect-ratio: 9 / 16;
}
.output-fallback {
display: grid;
min-height: min(55vh, 32rem);
place-content: center;
padding: 2rem;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 1.2rem;
background: rgb(255 255 255 / 5%);
text-align: center;
}
.presentation-test-status {
position: fixed;
z-index: 2;
top: 1rem;
right: 1rem;
padding: 0.45rem 0.7rem;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 2rem;
background: rgb(11 18 32 / 82%);
color: #67dde8;
font-size: 0.7rem;
font-weight: 750;
}
.output-idle {
display: grid;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
@@ -1368,6 +1473,40 @@ a {
font-size: clamp(0.9rem, 1.8vw, 1.3rem);
}
@media (max-width: 52rem) {
.heinzlive-output {
padding: 1.2rem;
}
.output-link-card {
grid-template-columns: 1fr;
align-content: center;
gap: 1.25rem;
}
.output-link-image {
min-height: 12rem;
}
.output-link-image img {
max-height: 40vh;
}
.output-video {
min-height: calc(100dvh - 2.4rem);
}
.output-video header {
align-items: flex-start;
flex-direction: column;
gap: 0;
}
.output-player iframe {
width: 100%;
}
}
.quiet-panel {
border: 1px solid var(--line);
border-radius: var(--radius-large);
@@ -1853,6 +1992,77 @@ a {
color: var(--text);
}
.filter-checkbox {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2rem;
margin: -0.35rem 0 0.75rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 680;
cursor: pointer;
}
.filter-checkbox input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: var(--accent);
}
.library-filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 1rem;
margin: -0.35rem 0 0.75rem;
}
.library-filter-options .filter-checkbox {
margin: 0;
}
.piece-usage-history {
margin-top: 0.35rem;
color: var(--text-muted);
font-size: 0.65rem;
}
.piece-usage-history p {
margin: 0;
}
.piece-usage-history a {
color: var(--accent);
font-weight: 700;
}
.piece-usage-history p span {
margin-left: 0.3rem;
color: var(--text-faint);
}
.piece-usage-history details {
margin-top: 0.25rem;
}
.piece-usage-history summary {
width: fit-content;
cursor: pointer;
font-weight: 680;
}
.piece-usage-history ul {
display: grid;
gap: 0.2rem;
margin: 0.3rem 0 0;
padding-left: 1rem;
}
.piece-usage-history li a {
margin-left: 0.35rem;
}
.text-link {
color: var(--accent);
font-size: 0.72rem;
@@ -2081,6 +2291,71 @@ a {
grid-column: 1 / -1;
}
.presentation-mode-picker {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.85rem;
}
.presentation-mode-picker label {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
border: 1px solid var(--line);
border-radius: 2rem;
background: var(--panel-soft);
font-size: 0.74rem;
font-weight: 700;
}
.presentation-settings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 0.8rem;
}
.presentation-settings > label,
.presentation-text-field {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.presentation-preview-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.presentation-preview-actions small,
.presentation-source-summary small {
color: var(--text-faint);
font-size: 0.66rem;
}
.presentation-source-summary {
display: grid;
gap: 0.2rem;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--panel-soft);
}
.presentation-source-summary span {
color: var(--text-muted);
font-size: 0.68rem;
}
.presentation-source-summary small {
overflow-wrap: anywhere;
}
.editor-fields label,
.markdown-grid > label,
.source-row label {
@@ -2466,7 +2741,8 @@ a {
}
.copy-field button,
.row-actions button {
.row-actions > button,
.row-actions > form > button {
min-width: 2.35rem;
min-height: 2.35rem;
border: 1px solid var(--line);
@@ -2476,7 +2752,156 @@ a {
cursor: pointer;
}
.row-actions button:disabled {
.usage-edit-button {
display: grid;
width: 1.8rem;
height: 1.8rem;
place-items: center;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.usage-edit-button:hover {
border-color: var(--accent);
color: var(--accent);
}
.usage-editor-dialog {
width: min(60rem, calc(100vw - 2rem));
max-width: none;
max-height: min(52rem, calc(100dvh - 2rem));
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-large);
background: var(--bg-elevated);
box-shadow: var(--shadow-large);
color: var(--text);
}
.usage-editor-dialog::backdrop {
background: color-mix(in srgb, #07110e 62%, transparent);
backdrop-filter: blur(6px);
}
.usage-editor-dialog > header {
display: flex;
position: sticky;
z-index: 2;
top: 0;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
backdrop-filter: blur(16px);
}
.usage-editor-dialog h2,
.usage-editor-dialog h3 {
margin: 0.12rem 0 0;
}
.usage-editor-dialog h2 {
font-size: 1.2rem;
}
.usage-editor-dialog h3 {
font-size: 0.95rem;
}
.dialog-close {
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 1.25rem;
cursor: pointer;
}
.usage-dialog-scroll {
display: grid;
gap: 0.8rem;
max-height: calc(100dvh - 7rem);
padding: 0.9rem;
overflow: auto;
}
.usage-dialog-message {
margin: 0;
}
.usage-dialog-editor {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.35fr);
align-items: stretch;
gap: 0.8rem;
}
.usage-dialog-editor > .primary-button {
grid-column: 1 / -1;
justify-self: end;
}
.usage-dialog-editor input,
.usage-dialog-editor textarea,
.usage-dialog-actions select {
width: 100%;
min-width: 0;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: 0.7rem;
background: var(--panel-strong);
color: var(--text);
}
.usage-dialog-actions {
display: grid;
grid-template-columns: minmax(14rem, 1fr) auto minmax(13rem, auto) auto;
align-items: end;
gap: 0.65rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.usage-dialog-actions > div {
display: grid;
gap: 0.3rem;
}
.usage-dialog-actions small {
color: var(--text-faint);
font-size: 0.62rem;
line-height: 1.35;
}
.usage-dialog-actions form,
.usage-dialog-actions label {
display: flex;
align-items: end;
gap: 0.4rem;
}
.usage-dialog-actions label {
min-width: 9rem;
flex-direction: column;
align-items: stretch;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 700;
}
.row-actions > button:disabled,
.row-actions > form > button:disabled {
cursor: not-allowed;
opacity: 0.35;
}
@@ -2500,6 +2925,17 @@ a {
margin-bottom: 0.45rem;
}
.builder-filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.8rem;
margin-bottom: 0.35rem;
}
.builder-filter-options .filter-checkbox {
margin: 0;
}
.builder-source-list {
display: grid;
border-top: 1px solid var(--line);
@@ -2534,6 +2970,17 @@ a {
font-size: 0.67rem;
}
.planned-label {
display: inline-block;
margin-top: 0.35rem;
padding: 0.16rem 0.4rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.61rem;
font-weight: 760;
}
.builder-source-card h3 {
margin: 0.4rem 0 0.12rem;
font-size: 0.86rem;
@@ -2674,11 +3121,12 @@ a {
gap: 0.25rem;
}
.row-actions form {
.row-actions > form {
display: flex;
}
.row-actions button {
.row-actions > button,
.row-actions > form > button {
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0;
@@ -2780,7 +3228,7 @@ a {
.reserve-usage {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) auto;
grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) auto auto;
align-items: end;
gap: 0.5rem;
margin-top: 0.55rem;
@@ -2797,7 +3245,7 @@ a {
font-size: 0.65rem;
}
.reserve-usage form {
.reserve-usage > form {
display: flex;
align-items: end;
gap: 0.35rem;
@@ -2952,6 +3400,8 @@ a {
.show-create-form,
.show-settings-form,
.usage-edit-form,
.usage-dialog-editor,
.presentation-settings,
.builder-filter {
grid-template-columns: 1fr;
}
@@ -2990,6 +3440,23 @@ a {
flex-direction: column;
gap: 0.75rem;
}
.usage-editor-dialog {
width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
border-radius: var(--radius-medium);
}
.usage-dialog-scroll {
max-height: calc(100dvh - 5.5rem);
padding: 0.65rem;
}
.usage-dialog-actions {
grid-template-columns: 1fr;
align-items: stretch;
}
.usage-dialog-actions form,
.usage-dialog-actions button {
width: 100%;
}
.library-toolbar {
align-items: stretch;
flex-direction: column;
@@ -3103,7 +3570,8 @@ a {
grid-template-columns: 1fr;
}
.copy-field button,
.reserve-usage button {
.reserve-usage > button,
.reserve-usage > form > button {
width: 100%;
}
.editable-run-section > .row-actions {
+7 -2
View File
@@ -14,6 +14,7 @@ const publicPaths = new Set([
export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
@@ -26,9 +27,13 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
if (presentationPath) {
if (presentationPath || presentationTestPath) {
response.headers.set('Cache-Control', 'no-store');
response.headers.set('Referrer-Policy', 'no-referrer');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Content-Security-Policy',
"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'"
);
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store');
+2
View File
@@ -16,6 +16,8 @@
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{:else if name === 'edit'}
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
{/if}
</svg>
@@ -0,0 +1,36 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { SentPieceUsage } from '#lib/server/shows.js';
let { history }: { history: SentPieceUsage[] } = $props();
function berlinDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium'
}).format(new Date(value));
}
</script>
{#if history.length}
<div class="piece-usage-history">
<p>
<strong>Gesendet am {berlinDate(history[0].usedAtUtc)}</strong>
in <a href={resolve('/sendungen/[id]', { id: history[0].showId })}>{history[0].showTitle}</a>
{#if history.length > 1}<span>+ {history.length - 1} weitere</span>{/if}
</p>
{#if history.length > 1}
<details>
<summary>Vollständige Verwendungshistorie</summary>
<ul>
{#each history as usage (usage.usageId)}
<li>
<time datetime={usage.usedAtUtc}>{berlinDate(usage.usedAtUtc)}</time>
<a href={resolve('/sendungen/[id]', { id: usage.showId })}>{usage.showTitle}</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
let {
content,
imageSrc = ''
}: {
content: PresentationContent & {
sources?: { label: string; url: string }[];
images?: { id: string; altText: string }[];
};
imageSrc?: string;
} = $props();
let frame = $state<HTMLIFrameElement>();
let playerUnavailable = $state(false);
let imageUnavailable = $state(false);
onMount(() => {
const receive = (event: MessageEvent) => {
if (content.provider !== 'tiktok' || event.origin !== 'https://www.tiktok.com') return;
if (event.source !== frame?.contentWindow || typeof event.data !== 'object' || !event.data)
return;
const message = event.data as { type?: string; value?: unknown };
if (message.type === 'onPlayerReady' && content.source?.timecodeSeconds) {
frame.contentWindow?.postMessage(
{
type: 'seekTo',
value: content.source.timecodeSeconds,
'x-tiktok-player': true
},
'https://www.tiktok.com'
);
}
if (message.type === 'onPlayerError') playerUnavailable = true;
};
addEventListener('message', receive);
return () => removeEventListener('message', receive);
});
</script>
{#if content.mode === 'text'}
<article class="output-content output-text">
<p class="output-kicker">heinzlive</p>
<h1>{content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html content.html}</div>
{#if content.images?.length}<div class="output-images">
{#each content.images as image (image.id)}
<img src={`${imageSrc}/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if content.sources?.length}<footer class="output-sources">
<span>Quellen</span>
{#each content.sources as source, index (`${index}-${source.url}`)}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else if content.mode === 'link'}
<article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
</div>{/if}
<div class="output-link-copy">
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
<h1>{content.previewTitle || content.title}</h1>
{#if content.description}<p>{content.description}</p>{/if}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</div>
</article>
{:else}
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
<header>
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
<h1>{content.previewTitle || content.title}</h1>
</header>
{#if content.embedUrl && !playerUnavailable}
<div class="output-player">
<iframe
bind:this={frame}
title={content.previewTitle || content.title}
src={content.embedUrl}
allow="encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
onerror={() => (playerUnavailable = true)}
></iframe>
</div>
{:else}
<section class="output-fallback">
<strong>Video kann nicht eingebettet werden</strong>
<p>Der Anbieter oder diese URL ist für die eingebettete Wiedergabe nicht verfügbar.</p>
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</section>
{/if}
{#if content.source}<footer class="output-video-source">
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={content.source.url} target="_blank" rel="noopener noreferrer">Original öffnen</a>
</footer>{/if}
</article>
{/if}
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import NavIcon from './NavIcon.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
usage,
revision,
sectionId,
sections,
error,
enhanceCommand
}: {
usage: PieceUsage;
revision: number;
sectionId: string;
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
} = $props();
let dialog: HTMLDialogElement;
let presentationMode = $state(untrack(() => usage.presentationMode));
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
</script>
<button
class="usage-edit-button"
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()}
>
<NavIcon name="edit" />
</button>
<dialog
bind:this={dialog}
class="usage-editor-dialog"
aria-labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()}
>
<header>
<div>
<p class="eyebrow">Sendungsspezifische Kopie</p>
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
</div>
<form method="dialog">
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
</form>
</header>
<div class="usage-dialog-scroll">
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-dialog-editor"
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="editor-section">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h3>Titel, Dauer und Notizen</h3>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" value={usage.title} /></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h3>Getrennte Arbeitsbereiche</h3>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
>{usage.intro}</textarea
></label
>
<label class="private-field"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
>{usage.research}</textarea
></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderation"
rows="7"
maxlength="50000">{usage.moderation}</textarea
></label
>
<label
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
>{usage.transition}</textarea
></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
>{usage.presentation}</textarea
></label
>
</div>
<div class="presentation-settings">
<label
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
<option value="text">Text</option>
<option value="link">Linkvorschau</option>
<option value="video">Video</option>
</select></label
>
{#if presentationMode !== 'text'}
<input
type="hidden"
name="presentationSourceLabel"
value={usage.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={usage.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{usage.presentationSourceLabel ||
usage.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
value={usage.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>
{/if}
</div>
</section>
<button class="primary-button" type="submit">Verwendung speichern</button>
</form>
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
<div>
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
>Aus Fundus aktualisieren</button
>
</form>
<small
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit">Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="position" value="9999" />
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each sections as target (target.id)}
<option value={target.id} selected={target.id === sectionId}>{target.name}</option>
{/each}
</select></label
>
<button class="secondary-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button danger-text" type="submit">Entfernen</button>
</form>
</section>
</div>
</dialog>
+56
View File
@@ -13,6 +13,7 @@ import {
getPiece,
getTaxonomy,
listPieces,
loadPiecePresentationMetadata,
savePiece,
setPieceArchived,
uploadPieceImage,
@@ -50,6 +51,18 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
moderationNotes: '- Punkt eins',
transitionText: 'Weiter geht es.',
presentationText: '## Sichtbare Aussage',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
presentationDescription: '',
presentationImageUrl: '',
presentationSiteName: '',
presentationProvider: '',
presentationProviderId: '',
presentationTitleManual: false,
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction'],
sources: [
@@ -86,6 +99,49 @@ describe('content management', () => {
});
});
it('stores fetched preview metadata and preserves manual corrections on reload', async () => {
const id = createPiece('find');
const initial = getPiece(id)!;
const saved = savePiece(
id,
initial.revision,
completeInput({
presentationMode: 'link',
presentationSourcePosition: 0,
presentationTitle: 'Manueller Titel',
presentationTitleManual: true
})
);
const loaded = await loadPiecePresentationMetadata(id, saved.revision, async () => ({
title: 'Automatischer Titel',
description: 'Geladener Anreißer',
imageUrl: 'https://example.com/vorschau.jpg',
siteName: 'Example',
provider: '',
providerId: ''
}));
expect(getPiece(id)).toMatchObject({
revision: loaded.revision,
presentationTitle: 'Manueller Titel',
presentationDescription: 'Geladener Anreißer',
presentationImageUrl: 'https://example.com/vorschau.jpg',
presentationSiteName: 'Example'
});
await loadPiecePresentationMetadata(id, loaded.revision, async () => ({
title: 'Anderer automatischer Titel',
description: 'Aktualisierter Anreißer',
imageUrl: '',
siteName: 'Example',
provider: '',
providerId: ''
}));
expect(getPiece(id)).toMatchObject({
presentationTitle: 'Manueller Titel',
presentationDescription: 'Aktualisierter Anreißer'
});
});
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
+190 -3
View File
@@ -4,6 +4,11 @@ import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
import {
detectVideoProvider,
fetchPresentationMetadata,
type PresentationMode
} from './presentation.js';
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number];
@@ -27,6 +32,18 @@ export type PieceInput = {
moderationNotes: string;
transitionText: string;
presentationText: string;
presentationMode: PresentationMode;
presentationSourcePosition: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProviderId: string;
presentationTitleManual: boolean;
presentationDescriptionManual: boolean;
presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
@@ -164,6 +181,25 @@ function validatePieceInput(input: PieceInput): PieceInput {
}
return { label, url, timecodeSeconds: source.timecodeSeconds };
});
if (!['text', 'link', 'video'].includes(input.presentationMode)) {
throw new Error('INVALID_PRESENTATION');
}
const sourcePosition = input.presentationSourcePosition;
if (
sourcePosition !== null &&
(!Number.isInteger(sourcePosition) || sourcePosition < 0 || sourcePosition >= sources.length)
) {
throw new Error('INVALID_PRESENTATION');
}
if (input.presentationMode !== 'text' && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION');
}
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
if (presentationImageUrl && !validUrl(presentationImageUrl)) {
throw new Error('INVALID_PRESENTATION');
}
const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return {
title,
summary: cleanText(input.summary, 2_000, 'summary'),
@@ -175,6 +211,22 @@ function validatePieceInput(input: PieceInput): PieceInput {
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
presentationMode: input.presentationMode,
presentationSourcePosition: sourcePosition,
presentationTitle: cleanText(input.presentationTitle, 300, 'presentation_title'),
presentationDescription: cleanText(
input.presentationDescription,
1_000,
'presentation_description'
),
presentationImageUrl,
presentationSiteName: cleanText(input.presentationSiteName, 200, 'presentation_site'),
presentationProvider: detected?.provider ?? '',
presentationProviderId: detected?.id ?? '',
presentationTitleManual: Boolean(input.presentationTitleManual),
presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
topicIds,
categoryIds,
sources
@@ -298,6 +350,18 @@ type PieceRow = {
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_position: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
presentation_title_manual: number;
presentation_description_manual: number;
presentation_image_manual: number;
presentation_metadata_fetched_at_utc: string | null;
revision: number;
created_at_utc: string;
updated_at_utc: string;
@@ -308,7 +372,12 @@ export function getPiece(id: string): PieceEditor | null {
const row = database
.prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc
moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
presentation_image_manual, presentation_metadata_fetched_at_utc,
revision, created_at_utc, updated_at_utc
FROM pieces WHERE id = ?`
)
.get(id) as PieceRow | undefined;
@@ -325,6 +394,18 @@ export function getPiece(id: string): PieceEditor | null {
moderationNotes: row.moderation_notes,
transitionText: row.transition_text,
presentationText: row.presentation_text,
presentationMode: row.presentation_mode,
presentationSourcePosition: row.presentation_source_position,
presentationTitle: row.presentation_title,
presentationDescription: row.presentation_description,
presentationImageUrl: row.presentation_image_url,
presentationSiteName: row.presentation_site_name,
presentationProvider: row.presentation_provider,
presentationProviderId: row.presentation_provider_id,
presentationTitleManual: row.presentation_title_manual === 1,
presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
@@ -405,6 +486,11 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?,
presentation_metadata_fetched_at_utc = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'`
)
@@ -419,6 +505,18 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.moderationNotes,
input.transitionText,
input.presentationText,
input.presentationMode,
input.presentationSourcePosition,
input.presentationTitle,
input.presentationDescription,
input.presentationImageUrl,
input.presentationSiteName,
input.presentationProvider,
input.presentationProviderId,
input.presentationTitleManual ? 1 : 0,
input.presentationDescriptionManual ? 1 : 0,
input.presentationImageManual ? 1 : 0,
input.presentationMetadataFetchedAtUtc,
updatedAtUtc,
id,
expectedRevision
@@ -477,6 +575,18 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
moderationNotes: defaults.moderationNotes ?? '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
presentationDescription: '',
presentationImageUrl: '',
presentationSiteName: '',
presentationProvider: '',
presentationProviderId: '',
presentationTitleManual: false,
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
sources: []
@@ -582,8 +692,13 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)`
research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
@@ -596,6 +711,18 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.moderationNotes,
source.transitionText,
source.presentationText,
source.presentationMode,
source.presentationSourcePosition,
source.presentationTitle,
source.presentationDescription,
source.presentationImageUrl,
source.presentationSiteName,
source.presentationProvider,
source.presentationProviderId,
source.presentationTitleManual ? 1 : 0,
source.presentationDescriptionManual ? 1 : 0,
source.presentationImageManual ? 1 : 0,
source.presentationMetadataFetchedAtUtc,
now,
now
);
@@ -627,6 +754,66 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
}
}
export async function loadPiecePresentationMetadata(
id: string,
expectedRevision: number,
metadataLoader = fetchPresentationMetadata
) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const database = getDatabase();
const piece = database
.prepare(
`SELECT p.revision, p.presentation_mode, p.presentation_source_position,
p.presentation_title_manual, p.presentation_description_manual,
p.presentation_image_manual, s.url
FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id
AND s.position = p.presentation_source_position
WHERE p.id = ? AND p.status != 'archived'`
)
.get(id) as
| {
revision: number;
presentation_mode: PresentationMode;
presentation_source_position: number | null;
presentation_title_manual: number;
presentation_description_manual: number;
presentation_image_manual: number;
url: string | null;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (piece.revision !== expectedRevision) throw new Error('CONFLICT');
if (piece.presentation_mode === 'text' || !piece.url) throw new Error('INVALID_PRESENTATION');
const metadata = await metadataLoader(piece.url);
const now = new Date().toISOString();
const result = database
.prepare(
`UPDATE pieces SET
presentation_title = CASE WHEN presentation_title_manual = 1 THEN presentation_title ELSE ? END,
presentation_description = CASE WHEN presentation_description_manual = 1 THEN presentation_description ELSE ? END,
presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END,
presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?,
presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(
metadata.title,
metadata.description,
metadata.imageUrl,
metadata.siteName,
metadata.provider,
metadata.providerId,
now,
now,
id,
expectedRevision
);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 5,
migrations: 6,
pieces: 6,
shows: 2,
usages: 5,
+48
View File
@@ -134,4 +134,52 @@ describe('live mode', () => {
'Die kleine Konsole mit dem großen Dreh'
);
});
it('projects only snapshotted link and video data and changes players explicitly', () => {
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'video',
presentation_source_label_snapshot = 'YouTube',
presentation_source_url_snapshot = 'https://youtu.be/dQw4w9WgXcQ',
presentation_source_timecode_snapshot = 75,
presentation_title_meta_snapshot = 'Videotitel',
presentation_site_name_snapshot = 'YouTube',
presentation_provider_snapshot = 'youtube',
presentation_provider_id_snapshot = 'dQw4w9WgXcQ'
WHERE id = 'usage-console'`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'link',
presentation_source_label_snapshot = 'Artikel',
presentation_source_url_snapshot = 'https://example.com/artikel',
presentation_title_meta_snapshot = 'Linktitel',
presentation_description_snapshot = 'Öffentlicher Anreißer',
presentation_site_name_snapshot = 'Example'
WHERE id = 'usage-effects'`
)
.run();
const token = issuePresentationAccess('show-october');
presentUsage('show-october', 'usage-console', 'present-video', 0);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'video',
previewTitle: 'Videotitel',
embedUrl: 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75',
source: { url: 'https://youtu.be/dQw4w9WgXcQ', timecodeSeconds: 75 }
});
presentUsage('show-october', 'usage-effects', 'present-link', 1);
const link = getPresentationView(token)!;
expect(link.content).toMatchObject({
mode: 'link',
previewTitle: 'Linktitel',
description: 'Öffentlicher Anreißer',
embedUrl: '',
source: { label: 'Artikel', url: 'https://example.com/artikel' }
});
const serialized = JSON.stringify(link);
expect(serialized).not.toContain('Herstellerangaben');
expect(serialized).not.toContain('Moderationsstichpunkte');
});
});
+80 -20
View File
@@ -3,6 +3,7 @@ import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
import {
getShow,
movePieceUsage,
@@ -67,8 +68,17 @@ export type LiveView = {
export type PresentationView = {
revision: number;
content: null | {
mode: PresentationContent['mode'];
title: string;
html: string;
source: PresentationContent['source'];
previewTitle: string;
description: string;
imageUrl: string;
siteName: string;
provider: PresentationContent['provider'];
providerId: string;
embedUrl: string;
sources: { label: string; url: string; timecodeSeconds: number | null }[];
images: { id: string; altText: string }[];
};
@@ -506,45 +516,95 @@ export function getPresentationView(token: string): PresentationView | null {
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
u.presentation_provider_snapshot, u.presentation_provider_id_snapshot
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(state.presentation_usage_id, showId) as
| { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null }
| {
title_snapshot: string;
presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode'];
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
presentation_title_meta_snapshot: string;
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: PresentationContent['provider'];
presentation_provider_id_snapshot: string;
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources = usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
const sources =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images = usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
title: usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot
? {
label: usage.presentation_source_label_snapshot,
url: usage.presentation_source_url_snapshot,
timecodeSeconds: usage.presentation_source_timecode_snapshot
}
: null,
previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot,
siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot,
embedUrl: buildEmbedUrl(
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot
),
sources,
images
}
};
}
export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null;
try {
return await fetchPresentationImage(view.content.imageUrl);
} catch {
return null;
}
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
buildEmbedUrl,
detectVideoProvider,
extractMetadataFromHtml,
fetchPresentationMetadata
} from './presentation.js';
describe('presentation metadata and providers', () => {
it('recognizes supported YouTube URL forms and creates privacy-enhanced embeds with start time', () => {
for (const url of [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://youtube.com/live/dQw4w9WgXcQ'
]) {
expect(detectVideoProvider(url)).toEqual({ provider: 'youtube', id: 'dQw4w9WgXcQ' });
}
expect(buildEmbedUrl('youtube', 'dQw4w9WgXcQ', 75)).toBe(
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
);
expect(detectVideoProvider('https://evil.example/watch?v=dQw4w9WgXcQ')).toBeNull();
expect(detectVideoProvider('https://youtube.com/watch?v=invalid')).toBeNull();
});
it('recognizes canonical TikTok videos without inventing a start parameter', () => {
expect(
detectVideoProvider('https://www.tiktok.com/@scout2015/video/6718335390845095173')
).toEqual({ provider: 'tiktok', id: '6718335390845095173' });
expect(buildEmbedUrl('tiktok', '6718335390845095173', 90)).toBe(
'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1'
);
});
it('extracts generic metadata from local HTML and tolerates a missing image', async () => {
const metadata = await extractMetadataFromHtml(
`<!doctype html><html><head>
<meta property="og:title" content="Lokale Vorschau">
<meta property="og:description" content="Ein kurzer Anreißer">
<meta property="og:site_name" content="Testseite">
</head></html>`,
'https://example.com/artikel'
);
expect(metadata).toMatchObject({
title: 'Lokale Vorschau',
description: 'Ein kurzer Anreißer',
imageUrl: '',
siteName: 'Testseite'
});
});
it('rejects malformed and private metadata targets before a network request', async () => {
await expect(fetchPresentationMetadata('keine-url')).rejects.toThrow('FETCH_INVALID_URL');
await expect(fetchPresentationMetadata('http://127.0.0.1/private')).rejects.toThrow(
'FETCH_BLOCKED'
);
await expect(fetchPresentationMetadata('http://[::1]/private')).rejects.toThrow(
'FETCH_BLOCKED'
);
});
});
+274
View File
@@ -0,0 +1,274 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import http from 'node:http';
import https from 'node:https';
import metascraper from 'metascraper';
import description from 'metascraper-description';
import image from 'metascraper-image';
import publisher from 'metascraper-publisher';
import title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = {
title: string;
description: string;
imageUrl: string;
siteName: string;
provider: VideoProvider | '';
providerId: string;
};
export type PresentationSource = {
label: string;
url: string;
timecodeSeconds: number | null;
};
export type PresentationContent = {
mode: PresentationMode;
title: string;
html: string;
source: PresentationSource | null;
previewTitle: string;
description: string;
imageUrl: string;
siteName: string;
provider: VideoProvider | '';
providerId: string;
embedUrl: string;
};
const scrape = metascraper([title(), description({ truncateLength: 500 }), image(), publisher()]);
const MAX_HTML_BYTES = 1024 * 1024;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
function isPrivateIpv4(address: string) {
const parts = address.split('.').map(Number);
return (
parts[0] === 0 ||
parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 169 && parts[1] === 254) ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) ||
parts[0] >= 224
);
}
function isPrivateAddress(address: string) {
if (isIP(address) === 4) return isPrivateIpv4(address);
const normalized = address.toLowerCase().split('%')[0];
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8')) {
return true;
}
if (
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
) {
return true;
}
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? isPrivateIpv4(mapped) : false;
}
async function publicAddress(url: URL) {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('FETCH_BLOCKED');
}
if (url.port && url.port !== (url.protocol === 'https:' ? '443' : '80')) {
throw new Error('FETCH_BLOCKED');
}
const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
throw new Error('FETCH_BLOCKED');
}
const addresses = isIP(hostname)
? [{ address: hostname, family: isIP(hostname) }]
: await lookup(hostname, { all: true, verbatim: true }).catch(() => {
throw new Error('FETCH_UNAVAILABLE');
});
if (!addresses.length || addresses.some((entry) => isPrivateAddress(entry.address))) {
throw new Error('FETCH_BLOCKED');
}
return addresses[0];
}
async function requestPublic(
input: string,
accept: string,
maximumBytes: number,
redirects = 0
): Promise<{ url: string; contentType: string; data: Buffer }> {
let url: URL;
try {
url = new URL(input);
} catch {
throw new Error('FETCH_INVALID_URL');
}
const target = await publicAddress(url);
const client = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const request = client.get(
url,
{
headers: {
Accept: accept,
'User-Agent': 'Sendewerk/0.5.1 (+https://git.hnz.io/hnzio/sendewerk)'
},
lookup: (_hostname, _options, callback) =>
callback(null, target.address, target.family as 4 | 6)
},
(response) => {
const status = response.statusCode ?? 0;
if (status >= 300 && status < 400 && response.headers.location) {
response.resume();
if (redirects >= MAX_REDIRECTS) return reject(new Error('FETCH_REDIRECTS'));
const next = new URL(response.headers.location, url).href;
requestPublic(next, accept, maximumBytes, redirects + 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
response.resume();
return reject(new Error('FETCH_UNAVAILABLE'));
}
const chunks: Buffer[] = [];
let size = 0;
response.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > maximumBytes) {
request.destroy(new Error('FETCH_TOO_LARGE'));
return;
}
chunks.push(chunk);
});
response.on('end', () =>
resolve({
url: url.href,
contentType: String(response.headers['content-type'] ?? ''),
data: Buffer.concat(chunks)
})
);
}
);
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('FETCH_TIMEOUT')));
request.on('error', reject);
});
}
export function detectVideoProvider(input: string): {
provider: VideoProvider;
id: string;
} | null {
let url: URL;
try {
url = new URL(input);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
const host = url.hostname.toLowerCase();
let youtubeId = '';
if (host === 'youtu.be') youtubeId = url.pathname.split('/').filter(Boolean)[0] ?? '';
if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'm.youtube.com') {
if (url.pathname === '/watch') youtubeId = url.searchParams.get('v') ?? '';
else if (/^\/(shorts|embed|live)\//.test(url.pathname)) {
youtubeId = url.pathname.split('/').filter(Boolean)[1] ?? '';
}
}
if (/^[A-Za-z0-9_-]{11}$/.test(youtubeId)) {
return { provider: 'youtube', id: youtubeId };
}
if (host === 'tiktok.com' || host === 'www.tiktok.com' || host === 'm.tiktok.com') {
const id = url.pathname.match(/\/video\/(\d+)/)?.[1] ?? '';
if (/^\d+$/.test(id)) return { provider: 'tiktok', id };
}
return null;
}
export function buildEmbedUrl(
provider: VideoProvider | '',
providerId: string,
timecodeSeconds: number | null
) {
if (provider === 'youtube' && /^[A-Za-z0-9_-]{11}$/.test(providerId)) {
const url = new URL(`https://www.youtube-nocookie.com/embed/${providerId}`);
if (timecodeSeconds && timecodeSeconds > 0)
url.searchParams.set('start', String(timecodeSeconds));
return url.href;
}
if (provider === 'tiktok' && /^\d+$/.test(providerId)) {
const url = new URL(`https://www.tiktok.com/player/v1/${providerId}`);
url.searchParams.set('autoplay', '0');
url.searchParams.set('controls', '1');
return url.href;
}
return '';
}
export async function extractMetadataFromHtml(
html: string,
url: string
): Promise<PresentationMetadata> {
const metadata = await scrape({ html, url });
return {
title: String(metadata.title ?? '').slice(0, 300),
description: String(metadata.description ?? '').slice(0, 1_000),
imageUrl: String(metadata.image ?? '').slice(0, 2_000),
siteName: String(metadata.publisher ?? new URL(url).hostname).slice(0, 200),
provider: '',
providerId: ''
};
}
async function providerMetadata(url: string, provider: VideoProvider) {
const endpoint =
provider === 'youtube'
? `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url)}`
: `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
const response = await requestPublic(endpoint, 'application/json', 256 * 1024);
if (!response.contentType.toLowerCase().includes('json')) throw new Error('FETCH_INVALID_TYPE');
const value = JSON.parse(response.data.toString('utf8')) as Record<string, unknown>;
return {
title: String(value.title ?? '').slice(0, 300),
description: '',
imageUrl: String(value.thumbnail_url ?? '').slice(0, 2_000),
siteName: String(value.provider_name ?? (provider === 'youtube' ? 'YouTube' : 'TikTok')).slice(
0,
200
)
};
}
export async function fetchPresentationMetadata(url: string): Promise<PresentationMetadata> {
const video = detectVideoProvider(url);
if (video) {
const metadata = await providerMetadata(url, video.provider);
return { ...metadata, provider: video.provider, providerId: video.id };
}
const response = await requestPublic(url, 'text/html,application/xhtml+xml', MAX_HTML_BYTES);
const contentType = response.contentType.toLowerCase();
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
throw new Error('FETCH_INVALID_TYPE');
}
return extractMetadataFromHtml(response.data.toString('utf8'), response.url);
}
export async function fetchPresentationImage(url: string) {
const response = await requestPublic(
url,
'image/jpeg,image/png,image/webp,image/gif',
MAX_IMAGE_BYTES
);
const mimeType = response.contentType.split(';')[0].trim().toLowerCase();
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(mimeType)) {
throw new Error('FETCH_INVALID_TYPE');
}
return { data: response.data, mimeType };
}
+251 -2
View File
@@ -9,10 +9,13 @@ import {
addPieceUsage,
addShowSection,
createShow,
duplicatePieceUsage,
getPieceUsageContexts,
getShow,
listShowTemplates,
movePieceUsage,
moveShowSection,
refreshPieceUsageFromOriginal,
removePieceUsage,
removeShowSection,
updatePieceUsage,
@@ -46,9 +49,16 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
moderation: edited.moderation,
transition: edited.transition,
presentation: edited.presentation,
presentationMode: edited.presentationMode,
presentationSourceLabel: edited.presentationSourceLabel,
presentationSourceUrl: edited.presentationSourceUrl,
presentationSourceTimecodeSeconds: edited.presentationSourceTimecodeSeconds,
presentationTitle: edited.presentationTitle,
presentationDescription: edited.presentationDescription,
presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds,
status: edited.status
durationSeconds: edited.durationSeconds
};
}
@@ -153,6 +163,245 @@ describe('show builder data layer', () => {
});
});
it('derives sent history and current planning only from qualifying usages by original id', () => {
const completed = createShow({
title: 'Abgeschlossene Sendung',
startsAtUtc: '2027-01-10T19:00:00Z',
templateId: null
});
const completedSection = addShowSection(completed.id, completed.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const done = addPieceUsage(completed.id, completedSection.revision, {
pieceId: 'piece-console',
sectionId: completedSection.id
});
const skipped = addPieceUsage(completed.id, done.revision, {
pieceId: 'piece-effects',
sectionId: completedSection.id
});
const reserve = addPieceUsage(completed.id, skipped.revision, {
pieceId: 'piece-pixels',
sectionId: null
});
getDatabase()
.prepare("UPDATE piece_usages SET status = 'done' WHERE id IN (?, ?)")
.run(done.id, reserve.id);
getDatabase()
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
.run(skipped.id);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id);
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
const activeSection = addShowSection(active.id, active.revision, {
name: 'Plan',
categoryId: null,
explicitDurationSeconds: null
});
const planned = addPieceUsage(active.id, activeSection.revision, {
pieceId: 'piece-console',
sectionId: activeSection.id
});
const plannedReserve = addPieceUsage(active.id, planned.revision, {
pieceId: 'piece-effects',
sectionId: null
});
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(planned.id);
let contexts = getPieceUsageContexts(
['piece-console', 'piece-effects', 'piece-pixels'],
active.id
);
expect(contexts['piece-console']).toMatchObject({
isPlannedInCurrentShow: true,
sentHistory: [
{
usageId: done.id,
showId: completed.id,
showTitle: 'Abgeschlossene Sendung',
usedAtUtc: '2027-01-10T19:00:00.000Z'
}
]
});
expect(contexts['piece-effects']).toEqual({
isPlannedInActiveShow: true,
isPlannedInCurrentShow: true,
sentHistory: []
});
expect(contexts['piece-pixels']).toEqual({
isPlannedInActiveShow: true,
isPlannedInCurrentShow: false,
sentHistory: []
});
removePieceUsage(active.id, plannedReserve.id, plannedReserve.revision);
contexts = getPieceUsageContexts(['piece-effects'], active.id);
expect(contexts['piece-effects'].isPlannedInCurrentShow).toBe(false);
});
it('duplicates an adjusted usage directly after its source without live measurements', () => {
const created = createShow({
title: 'Einplanungsduplikat',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const usage = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const source = getShow(created.id)!.sections[0].usages[0];
const adjusted = updatePieceUsage(
created.id,
usage.id,
usage.revision,
editedUsage(source, {
title: 'Angepasste Einplanung',
durationSeconds: 777,
intro: 'Eigene Einleitung',
research: 'Eigene Recherche',
moderation: 'Eigene Moderation',
transition: 'Eigene Überleitung',
presentation: 'Eigene Präsentation',
ownNotes: 'Eigene Notizen'
})
);
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
getDatabase()
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc, ended_at_utc)
VALUES ('source-run', ?, ?, '2027-01-01T10:00:00.000Z', '2027-01-01T10:10:00.000Z')`
)
.run(created.id, usage.id);
const pieceCountBefore = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const duplicate = duplicatePieceUsage(created.id, usage.id, adjusted.revision);
const usages = getShow(created.id)!.sections[0].usages;
expect(usages.map((item) => item.id)).toEqual([usage.id, duplicate.id]);
expect(usages[1]).toMatchObject({
originalPieceId: 'piece-console',
status: 'open',
durationSeconds: 777,
title: 'Angepasste Einplanung',
intro: 'Eigene Einleitung',
research: 'Eigene Recherche',
moderation: 'Eigene Moderation',
transition: 'Eigene Überleitung',
presentation: 'Eigene Präsentation',
ownNotes: 'Eigene Notizen'
});
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get(duplicate.id)
).toEqual({ count: 0 });
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(pieceCountBefore);
getDatabase()
.prepare(
`UPDATE pieces SET title = 'Aktuelles Original', duration_seconds = 420,
intro = 'Neue Einleitung', research_text = 'Neue Recherche',
moderation_notes = 'Neue Moderation', transition_text = 'Neue Überleitung',
presentation_text = 'Neue Präsentation' WHERE id = 'piece-console'`
)
.run();
const refreshed = refreshPieceUsageFromOriginal(created.id, usage.id, duplicate.revision);
const refreshedUsages = getShow(created.id)!.sections[0].usages;
expect(refreshedUsages[0]).toMatchObject({
title: 'Aktuelles Original',
durationSeconds: 420,
intro: 'Neue Einleitung',
research: 'Neue Recherche',
moderation: 'Neue Moderation',
transition: 'Neue Überleitung',
presentation: 'Neue Präsentation',
ownNotes: 'Eigene Notizen',
status: 'done'
});
expect(refreshedUsages[1]).toMatchObject({
title: 'Angepasste Einplanung',
durationSeconds: 777,
status: 'open'
});
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get(usage.id)
).toEqual({ count: 1 });
expect(() => duplicatePieceUsage(created.id, usage.id, adjusted.revision)).toThrow('CONFLICT');
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
getDatabase()
.prepare('UPDATE piece_usages SET original_piece_id = NULL WHERE id = ?')
.run(duplicate.id);
expect(() =>
refreshPieceUsageFromOriginal(created.id, duplicate.id, refreshed.revision)
).toThrow('ORIGINAL_NOT_FOUND');
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
expect(getShow(created.id)!.sections[0].usages).toHaveLength(2);
});
it('snapshots presentation settings until an explicit refresh and copies them on duplication', () => {
getDatabase()
.prepare(
`UPDATE piece_sources SET label = 'YouTube', url = ?, timecode_seconds = 75
WHERE piece_id = 'piece-console' AND position = 0`
)
.run('https://youtu.be/dQw4w9WgXcQ');
getDatabase()
.prepare(
`UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0,
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
)
.run();
const show = createShow({ title: 'Snapshot-Test', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Videos',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75,
presentationTitle: 'Gespeicherte Vorschau'
});
getDatabase()
.prepare(
`UPDATE pieces SET presentation_title = 'Neuer Originaltitel' WHERE id = 'piece-console'`
)
.run();
expect(getShow(show.id)!.sections[0].usages[0].presentationTitle).toBe('Gespeicherte Vorschau');
const refreshed = refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.presentationTitle).toBe('Neuer Originaltitel');
duplicatePieceUsage(show.id, usage.id, refreshed.revision);
expect(getShow(show.id)!.sections[0].usages[1]).toMatchObject({
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationTitle: 'Neuer Originaltitel',
status: 'open'
});
});
it('persists normalized section and usage reordering, moving and removal', () => {
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
const firstSection = addShowSection(created.id, created.revision, {
+392 -9
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
@@ -46,9 +47,32 @@ export type PieceUsage = {
moderation: string;
transition: string;
presentation: string;
presentationMode: PresentationMode;
presentationSourceLabel: string;
presentationSourceUrl: string;
presentationSourceTimecodeSeconds: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProviderId: string;
ownNotes: string;
};
export type SentPieceUsage = {
usageId: string;
showId: string;
showTitle: string;
usedAtUtc: string;
};
export type PieceUsageContext = {
sentHistory: SentPieceUsage[];
isPlannedInActiveShow: boolean;
isPlannedInCurrentShow: boolean;
};
export type ShowSection = {
id: string;
name: string;
@@ -110,9 +134,16 @@ export type PieceUsageUpdate = {
moderation: string;
transition: string;
presentation: string;
presentationMode: PresentationMode;
presentationSourceLabel: string;
presentationSourceUrl: string;
presentationSourceTimecodeSeconds: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
ownNotes: string;
durationSeconds: number;
status: UsageStatus;
};
export type MovePieceUsageInput = {
@@ -159,6 +190,16 @@ type UsageRow = {
moderation_snapshot: string;
transition_snapshot: string;
presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode;
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
presentation_title_meta_snapshot: string;
presentation_description_snapshot: string;
presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string;
presentation_provider_snapshot: string;
presentation_provider_id_snapshot: string;
own_notes: string;
};
@@ -191,6 +232,50 @@ function validTimestamp(value: string | null): string | null {
return date.toISOString();
}
function validPresentation(
mode: PresentationMode,
sourceLabel: string,
sourceUrl: string,
timecodeSeconds: number | null,
title: string,
description: string,
imageUrl: string,
siteName: string
) {
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) {
if (!value) continue;
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
} catch {
throw new Error('INVALID_PRESENTATION');
}
}
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if (
timecodeSeconds !== null &&
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
) {
throw new Error('INVALID_PRESENTATION');
}
const detected = detectVideoProvider(cleanUrl);
return {
mode,
sourceLabel: cleanText(sourceLabel, 200, 'presentation_source'),
sourceUrl: cleanUrl,
timecodeSeconds,
title: cleanText(title, 300, 'presentation_title'),
description: cleanText(description, 1_000, 'presentation_description'),
imageUrl: cleanImageUrl,
siteName: cleanText(siteName, 200, 'presentation_site'),
provider: detected?.provider ?? '',
providerId: detected?.id ?? ''
};
}
function requireRevision(expectedRevision: number): void {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
@@ -296,6 +381,16 @@ function usageFromRow(row: UsageRow): PieceUsage {
moderation: row.moderation_snapshot,
transition: row.transition_snapshot,
presentation: row.presentation_snapshot,
presentationMode: row.presentation_mode_snapshot,
presentationSourceLabel: row.presentation_source_label_snapshot,
presentationSourceUrl: row.presentation_source_url_snapshot,
presentationSourceTimecodeSeconds: row.presentation_source_timecode_snapshot,
presentationTitle: row.presentation_title_meta_snapshot,
presentationDescription: row.presentation_description_snapshot,
presentationImageUrl: row.presentation_image_url_snapshot,
presentationSiteName: row.presentation_site_name_snapshot,
presentationProvider: row.presentation_provider_snapshot,
presentationProviderId: row.presentation_provider_id_snapshot,
ownNotes: row.own_notes
};
}
@@ -384,7 +479,12 @@ export function getShow(id: string): Show | null {
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
u.presentation_provider_snapshot, u.presentation_provider_id_snapshot, u.own_notes
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? ORDER BY s.position, u.position`
)
@@ -430,6 +530,80 @@ export function getShow(id: string): Show | null {
};
}
export function getPieceUsageContexts(
pieceIds: string[],
currentShowId?: string
): Record<string, PieceUsageContext> {
const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))];
const contexts = Object.fromEntries(
uniquePieceIds.map((pieceId) => [
pieceId,
{
sentHistory: [],
isPlannedInActiveShow: false,
isPlannedInCurrentShow: false
} satisfies PieceUsageContext
])
) as Record<string, PieceUsageContext>;
if (!uniquePieceIds.length) return contexts;
const database = getDatabase();
const history = database
.prepare(
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id,
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc
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 u.original_piece_id IS NOT NULL AND u.status = 'done'
AND sh.status = 'completed' AND s.is_reserve_bank = 0
ORDER BY used_at_utc DESC, u.id DESC`
)
.all() as {
usage_id: string;
original_piece_id: string;
show_id: string;
show_title: string;
used_at_utc: string;
}[];
for (const usage of history) {
contexts[usage.original_piece_id]?.sentHistory.push({
usageId: usage.usage_id,
showId: usage.show_id,
showTitle: usage.show_title,
usedAtUtc: usage.used_at_utc
});
}
const plannedInActiveShows = database
.prepare(
`SELECT DISTINCT u.original_piece_id 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 != 'completed' AND u.original_piece_id IS NOT NULL`
)
.all() as { original_piece_id: string }[];
for (const usage of plannedInActiveShows) {
const context = contexts[usage.original_piece_id];
if (context) context.isPlannedInActiveShow = true;
}
if (currentShowId) {
const planned = database
.prepare(
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND u.original_piece_id IS NOT NULL`
)
.all(currentShowId) as { original_piece_id: string }[];
for (const usage of planned) {
const context = contexts[usage.original_piece_id];
if (context) context.isPlannedInCurrentShow = true;
}
}
return contexts;
}
export function createShow(input: CreateShowInput): CreatedMutationResult {
const database = getDatabase();
const id = randomUUID();
@@ -631,8 +805,15 @@ export function addPieceUsage(
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database
.prepare(
`SELECT title, duration_seconds, intro, research_text, moderation_notes,
transition_text, presentation_text, status FROM pieces WHERE id = ?`
`SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?`
)
.get(input.pieceId) as
| {
@@ -643,6 +824,16 @@ export function addPieceUsage(
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_label: string;
presentation_source_url: string;
presentation_source_timecode: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
status: string;
}
| undefined;
@@ -659,8 +850,13 @@ export function addPieceUsage(
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)`
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -674,6 +870,16 @@ export function addPieceUsage(
piece.moderation_notes,
piece.transition_text,
piece.presentation_text,
piece.presentation_mode,
piece.presentation_source_label,
piece.presentation_source_url,
piece.presentation_source_timecode,
piece.presentation_title,
piece.presentation_description,
piece.presentation_image_url,
piece.presentation_site_name,
piece.presentation_provider,
piece.presentation_provider_id,
input.sectionId === null ? 1 : 0
);
ids.splice(position, 0, id);
@@ -682,6 +888,160 @@ export function addPieceUsage(
})();
}
export function duplicatePieceUsage(
showId: string,
usageId: string,
expectedRevision: number
): CreatedMutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const sourceSection = usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?`
)
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
is_reserve: number;
};
const ids = (
database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
.all(sourceSection.sectionId) as { id: string }[]
).map((row) => row.id);
const sourcePosition = ids.indexOf(usageId);
if (sourcePosition < 0) throw new Error('NOT_FOUND');
const id = randomUUID();
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
sourceSection.sectionId,
source.original_piece_id,
ids.length,
source.duration_seconds,
source.title_snapshot,
source.intro_snapshot,
source.research_snapshot,
source.moderation_snapshot,
source.transition_snapshot,
source.presentation_snapshot,
source.presentation_mode_snapshot,
source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot,
source.presentation_title_meta_snapshot,
source.presentation_description_snapshot,
source.presentation_image_url_snapshot,
source.presentation_site_name_snapshot,
source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot,
source.own_notes,
source.is_reserve
);
ids.splice(sourcePosition + 1, 0, id);
normalizePositions(database, 'piece_usages', ids);
return { ...result, id };
})();
}
export function refreshPieceUsageFromOriginal(
showId: string,
usageId: string,
expectedRevision: number
): MutationResult {
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position
WHERE u.id = ?`
)
.get(usageId) as
| {
title: string;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_label: string;
presentation_source_url: string;
presentation_source_timecode: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
}
| undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?`
)
.run(
source.title,
source.duration_seconds,
source.intro,
source.research_text,
source.moderation_notes,
source.transition_text,
source.presentation_text,
source.presentation_mode,
source.presentation_source_label,
source.presentation_source_url,
source.presentation_source_timecode,
source.presentation_title,
source.presentation_description,
source.presentation_image_url,
source.presentation_site_name,
source.presentation_provider,
source.presentation_provider_id,
usageId
);
return result;
})();
}
export function updatePieceUsage(
showId: string,
usageId: string,
@@ -690,7 +1050,6 @@ export function updatePieceUsage(
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const duration = validDuration(input.durationSeconds)!;
if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS');
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
@@ -699,6 +1058,16 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
};
const presentation = validPresentation(
input.presentationMode,
input.presentationSourceLabel,
input.presentationSourceUrl,
input.presentationSourceTimecodeSeconds,
input.presentationTitle,
input.presentationDescription,
input.presentationImageUrl,
input.presentationSiteName
);
const database = getDatabase();
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
@@ -707,7 +1076,12 @@ export function updatePieceUsage(
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?`
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?`
)
.run(
title,
@@ -716,9 +1090,18 @@ export function updatePieceUsage(
values.moderation,
values.transition,
values.presentation,
presentation.mode,
presentation.sourceLabel,
presentation.sourceUrl,
presentation.timecodeSeconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName,
presentation.provider,
presentation.providerId,
values.ownNotes,
duration,
input.status,
usageId
);
return result;
+18 -13
View File
@@ -10,6 +10,7 @@ import {
setPieceArchived,
type PieceTemplateId
} from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js';
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
@@ -18,21 +19,25 @@ export const load: PageServerLoad = ({ locals, url }) => {
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? ''
status: url.searchParams.get('status') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
};
const pieces = listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
});
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
return {
pieces: listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
}),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
templates: pieceTemplates,
filters
+75 -6
View File
@@ -1,9 +1,48 @@
<script lang="ts">
import { goto, replaceState } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0) &&
(showPlannedPieces || !piece.isPlannedInActiveShow)
)
);
onMount(() => {
const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams;
showUsedPieces = search.get('includeUsed') === '1';
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
const url = new URL(page.url.href);
if (checked) url.searchParams.set(name, '1');
else url.searchParams.delete(name);
// This URL is cloned from the current page; only same-route filter parameters change.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
}
async function resetFilters() {
await goto(resolve('/fundus'), { invalidateAll: true });
showUsedPieces = false;
showPlannedPieces = false;
}
const statusLabel = {
ready: 'Sendefertig',
preparing: 'In Vorbereitung',
@@ -20,8 +59,8 @@
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
@@ -41,7 +80,12 @@
</div>
</details>
<form class="library-filters" method="GET" aria-label="Fundus durchsuchen und filtern">
<form
id="library-filters"
class="library-filters"
method="GET"
aria-label="Fundus durchsuchen und filtern"
>
<label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
name="q"
@@ -84,12 +128,36 @@
></label
>
<button class="secondary-button" type="submit">Anwenden</button>
<a class="text-link" href={resolve('/fundus')}>Zurücksetzen</a>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</form>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="library-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
form="library-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
{#if data.pieces.length}
{#if visiblePieces.length}
<div class="piece-grid">
{#each data.pieces as piece (piece.id)}
{#each visiblePieces as piece (piece.id)}
<article
class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
@@ -104,6 +172,7 @@
<div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
<PieceUsageHistory history={piece.sentHistory} />
</div>
<div class="piece-classification">
<span class="category-label"
+45
View File
@@ -6,6 +6,7 @@ import {
duplicatePiece,
getPiece,
getTaxonomy,
loadPiecePresentationMetadata,
savePiece,
setPieceArchived,
uploadPieceImage,
@@ -65,6 +66,20 @@ function pieceInput(data: FormData): PieceInput {
moderationNotes: text(data, 'moderationNotes'),
transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'),
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
presentationSourcePosition: text(data, 'presentationSourcePosition')
? Number(data.get('presentationSourcePosition'))
: null,
presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'),
presentationProvider: text(data, 'presentationProvider'),
presentationProviderId: text(data, 'presentationProviderId'),
presentationTitleManual: text(data, 'presentationTitleManual') === '1',
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
presentationImageManual: text(data, 'presentationImageManual') === '1',
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String),
sources
@@ -87,6 +102,11 @@ function saveError(cause: unknown) {
status: 400,
error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.'
};
if (code === 'INVALID_PRESENTATION')
return {
status: 400,
error: 'Für Linkvorschau und Video muss eine vorhandene gültige Quelle ausgewählt sein.'
};
if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return {
@@ -116,6 +136,31 @@ export const actions = {
return fail(problem.status, { intent: 'save', ...problem });
}
},
loadPreview: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
await loadPiecePresentationMetadata(params.id, Number(data.get('revision')));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'FETCH_BLOCKED') {
return fail(400, {
intent: 'loadPreview',
error: 'Interne oder private Netzwerkziele dürfen nicht abgerufen werden.'
});
}
if (code.startsWith('FETCH_')) {
return fail(400, {
intent: 'loadPreview',
error:
'Die Vorschau konnte nicht sicher geladen werden. Die manuellen Angaben bleiben erhalten.'
});
}
const problem = saveError(cause);
return fail(problem.status, { intent: 'loadPreview', ...problem });
}
redirect(303, `/fundus/${params.id}`);
},
upload: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
+155 -3
View File
@@ -15,6 +15,7 @@
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
@@ -42,7 +43,17 @@
if (!saving) submitSave();
});
function scheduleSave() {
function scheduleSave(event?: Event) {
const control = event?.target;
if (event?.type === 'change' && !(control instanceof HTMLSelectElement)) return;
if (
control instanceof HTMLInputElement &&
control.name === 'presentationMode' &&
control.value !== 'text' &&
draft.presentationSourcePosition === null
) {
return;
}
generation += 1;
saveState = 'dirty';
saveMessage = 'Ungespeicherte Änderungen';
@@ -62,9 +73,25 @@
function removeSource(index: number) {
sources.splice(index, 1);
if (draft.presentationSourcePosition === index) draft.presentationSourcePosition = null;
else if (
draft.presentationSourcePosition !== null &&
draft.presentationSourcePosition > index
) {
draft.presentationSourcePosition -= 1;
}
scheduleSave();
}
function presentationSourceChanged() {
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
}
const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation;
saving = true;
@@ -248,7 +275,45 @@
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
<label class="wide-field"
</div>
</section>
<section class="editor-section presentation-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Präsentation</p>
<h2>Darstellung in heinzlive</h2>
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
</div>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">Präsentation testen</a
>
</div>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<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} />
<label class="presentation-text-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
@@ -256,7 +321,85 @@
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
</div>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label class="wide-field"
>Quelle<select
name="presentationSourcePosition"
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
>Titel<input
name="presentationTitle"
maxlength="300"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
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>
{/if}
<input
type="hidden"
name="presentationTitleManual"
value={draft.presentationTitleManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationDescriptionManual"
value={draft.presentationDescriptionManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationImageManual"
value={draft.presentationImageManual ? '1' : '0'}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
<section class="editor-section source-editor">
@@ -303,6 +446,15 @@
>
</div>
</form>
<form
bind:this={previewForm}
id="preview-load-form"
method="POST"
action="?/loadPreview"
use:enhance
>
<input type="hidden" name="revision" value={revision} />
</form>
<section class="editor-section media-editor">
<div class="section-title">
@@ -0,0 +1,44 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getPiece } from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
export const load: PageServerLoad = ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
const source =
piece.presentationSourcePosition === null
? null
: (piece.sources[piece.presentationSourcePosition] ?? null);
const content: PresentationContent & {
sources: typeof piece.sources;
images: { id: string; altText: string }[];
} = {
mode: piece.presentationMode,
title: piece.title,
html: renderMarkdown(piece.presentationText),
source,
previewTitle: piece.presentationTitle,
description: piece.presentationDescription,
imageUrl: piece.presentationImageUrl,
siteName: piece.presentationSiteName,
provider:
piece.presentationProvider === 'youtube' || piece.presentationProvider === 'tiktok'
? piece.presentationProvider
: '',
providerId: piece.presentationProviderId,
embedUrl: buildEmbedUrl(
piece.presentationProvider === 'youtube' || piece.presentationProvider === 'tiktok'
? piece.presentationProvider
: '',
piece.presentationProviderId,
source?.timecodeSeconds ?? null
),
sources: piece.sources,
images: piece.media.map((item) => ({ id: item.id, altText: item.altText }))
};
return { pieceId: piece.id, content };
};
@@ -0,0 +1,21 @@
<script lang="ts">
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>Präsentation testen | Sendewerk</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class="heinzlive-output presentation-test-output">
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay
content={data.content}
imageSrc={data.content.mode === 'text'
? '/media'
: `/fundus/${data.pieceId}/praesentation/preview-image`}
/>
</main>
@@ -0,0 +1,26 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getPiece } from '#lib/server/content.js';
import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar');
try {
const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'none'; sandbox"
}
});
} catch {
error(404, 'Vorschaubild nicht verfügbar');
}
};
+10 -21
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -40,30 +41,18 @@
</svelte:head>
<main class="heinzlive-output">
<div class="output-connection" class:problem={connection === 'interrupted'} role="status">
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div>
{#if view.content}
<article class="output-content">
<p class="output-kicker">heinzlive</p>
<h1>{view.content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html view.content.html}</div>
{#if view.content.images.length}<div class="output-images">
{#each view.content.images as image (image.id)}
<img src={`/heinzlive/${page.params.token}/media/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if view.content.sources.length}<footer class="output-sources">
<span>Quellen</span>
{#each view.content.sources as source, index (`${index}-${source.url}`)}
<!-- Source URLs are validated as absolute HTTP(S) URLs when the Piece is saved. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{#key view.revision}
<PresentationDisplay
content={view.content}
imageSrc={view.content.mode === 'text'
? `/heinzlive/${page.params.token}/media`
: `/heinzlive/${page.params.token}/preview-image`}
/>
{/key}
{:else}
<section class="output-idle">
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationPreviewImage } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const image = await getPresentationPreviewImage(params.token);
if (!image) error(404, 'Vorschaubild nicht verfügbar');
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'private, max-age=300',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'none'; sandbox"
}
});
};
+25 -13
View File
@@ -73,6 +73,10 @@
persistSettings();
}
function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) {
@@ -147,7 +151,7 @@
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
<section class="live-toolbar" aria-label="Live-Sitzung">
<form method="POST" action="?/start" use:enhance={enhanceCommand}>
<form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
@@ -155,7 +159,7 @@
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
</button>
</form>
<form method="POST" action="?/createOutput" use:enhance={enhanceOutput}>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Präsentationsfenster öffnen</button>
</form>
@@ -200,13 +204,13 @@
</section>{/if}
<div class="live-current-actions">
<form method="POST" action="?/back" use:enhance={enhanceCommand}>
<form method="POST" action={liveAction('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}>
<form method="POST" action={liveAction('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} />
@@ -217,13 +221,13 @@
: 'Präsentieren'}
</button>
</form>
<form method="POST" action="?/skip" 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="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}>
<form method="POST" action={liveAction('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} />
@@ -240,7 +244,7 @@
</p>
{#if data.live.completedCount}<form
method="POST"
action="?/back"
action={liveAction('back')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
@@ -285,7 +289,11 @@
</div>
<div class="live-run-actions">
{#if usage.status === 'open'}
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<form
method="POST"
action={liveAction('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} />
@@ -298,7 +306,11 @@
aria-label={`${usage.title} nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<form
method="POST"
action={liveAction('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} />
@@ -312,7 +324,7 @@
>
</form>
{/if}
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<form method="POST" action={liveAction('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} />
@@ -335,7 +347,7 @@
>{/if}
</div>
<div class="live-run-actions">
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<form method="POST" action={liveAction('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} />
@@ -347,7 +359,7 @@
aria-label={`${usage.title} in Reserve nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<form method="POST" action={liveAction('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} />
@@ -361,7 +373,7 @@
</form>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action="?/moveUsage"
action={liveAction('moveUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
+43 -8
View File
@@ -5,16 +5,19 @@ import { getTaxonomy, listPieces } from '#lib/server/content.js';
import {
addPieceUsage,
addShowSection,
duplicatePieceUsage,
getShow,
getPieceUsageContexts,
movePieceUsage,
moveShowSection,
refreshPieceUsageFromOriginal,
removePieceUsage,
removeShowSection,
saveShowMetadata,
updatePieceUsage,
updateShowSection,
type EditableShowStatus,
type UsageStatus
type PieceUsageUpdate
} from '#lib/server/shows.js';
function text(data: FormData, name: string) {
@@ -84,6 +87,8 @@ function actionError(cause: unknown) {
return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' };
if (code === 'PIECE_NOT_READY')
return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' };
if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' };
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
@@ -104,18 +109,25 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user);
const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden');
const pieces = listPieces({
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
});
const usageContexts = getPieceUsageContexts(
pieces.map((piece) => piece.id),
show.id
);
return {
show,
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: listPieces({
statuses: ['ready'],
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
}),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(),
filters: {
query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? ''
categoryId: url.searchParams.get('category') ?? '',
includeUsed: url.searchParams.get('includeUsed') === '1',
includePlanned: url.searchParams.get('includePlanned') === '1'
}
};
};
@@ -198,10 +210,33 @@ export const actions = {
moderation: text(data, 'moderation'),
transition: text(data, 'transition'),
presentation: text(data, 'presentation'),
status: text(data, 'status') as UsageStatus
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
presentationSourceLabel: text(data, 'presentationSourceLabel'),
presentationSourceUrl: text(data, 'presentationSourceUrl'),
presentationSourceTimecodeSeconds: text(data, 'presentationSourceTimecodeSeconds')
? Number(data.get('presentationSourceTimecodeSeconds'))
: null,
presentationTitle: text(data, 'presentationTitle'),
presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName')
})
);
},
duplicateUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('duplicateUsage', () =>
duplicatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
refreshUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('refreshUsage', () =>
refreshPieceUsageFromOriginal(params.id, text(data, 'usageId'), Number(data.get('revision')))
);
},
removeUsage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
+84 -106
View File
@@ -1,7 +1,12 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { replaceState } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { flip } from 'svelte/animate';
import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -23,9 +28,27 @@
let dropSectionId = $state('');
let dropPosition = $state(0);
let copyMessage = $state('');
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
let visiblePieces = $derived(
data.pieces.filter(
(piece) =>
(showUsedPieces || piece.sentHistory.length === 0) &&
(showPlannedPieces || !piece.isPlannedInCurrentShow)
)
);
let plannedCount = $derived(
data.show.sections.reduce((total, section) => total + section.usages.length, 0)
);
onMount(() => {
const syncFiltersFromHistory = () => {
const search = new URL(window.location.href).searchParams;
showUsedPieces = search.get('includeUsed') === '1';
showPlannedPieces = search.get('includePlanned') === '1';
};
window.addEventListener('popstate', syncFiltersFromHistory);
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
});
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
@@ -36,8 +59,15 @@
}).format(new Date(value));
}
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
if (name === 'includeUsed') showUsedPieces = checked;
else showPlannedPieces = checked;
const url = new URL(page.url.href);
if (checked) url.searchParams.set(name, '1');
else url.searchParams.delete(name);
// This URL is cloned from the current page; only same-route filter parameters change.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
}
function startDrag(kind: 'piece' | 'section' | 'usage', id: string, event: DragEvent) {
@@ -142,6 +172,7 @@
affected?.classList.add('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') formElement.closest<HTMLDialogElement>('dialog')?.close();
await update({ reset: result.type === 'success', invalidateAll: true });
affected?.classList.remove('is-updating');
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
@@ -264,9 +295,9 @@
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{data.pieces.length}</span>
<span>{visiblePieces.length}</span>
</header>
<form method="GET" class="builder-filter">
<form id="builder-filters" method="GET" class="builder-filter">
<label
><span class="sr-only">Fundus durchsuchen</span><input
type="search"
@@ -286,8 +317,32 @@
>
<button class="text-button" type="submit">Filtern</button>
</form>
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="builder-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
form="builder-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
<div class="builder-source-list">
{#each data.pieces as piece (piece.id)}
{#each visiblePieces as piece (piece.id)}
<article
class="builder-source-card"
class:is-dragging={dragged?.kind === 'piece' && dragged.id === piece.id}
@@ -310,6 +365,8 @@
<p>
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
</p>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
<PieceUsageHistory history={piece.sentHistory} />
<small class="drag-hint">Ziehen oder Ziel auswählen</small>
</div>
<form
@@ -338,10 +395,13 @@
</article>
{/each}
</div>
{#if !data.pieces.length}<div class="empty-state">
{#if !visiblePieces.length}<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Nichts gefunden</h2>
<p>Filter ändern oder Pieces im Fundus sendefertig markieren.</p>
<h2>Keine verfügbaren Pieces</h2>
<p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
markieren.
</p>
</div>{/if}
</section>
@@ -507,105 +567,15 @@
aria-label={`${usage.title} nach unten`}>↓</button
>
</form>
<UsageEditorDialog
{usage}
revision={data.show.revision}
sectionId={section.id}
sections={data.show.sections}
error={form?.error}
{enhanceCommand}
/>
</div>
<details class="usage-editor">
<summary>Bearbeiten und verschieben</summary>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-edit-form"
>
<input type="hidden" name="revision" value={data.show.revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<label
>Titel<input
name="title"
required
maxlength="200"
value={usage.title}
/></label
><label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="2"
>{usage.ownNotes}</textarea
></label
><label class="wide-field"
>Einleitung<textarea name="intro" rows="2">{usage.intro}</textarea
></label
><label class="wide-field"
>Recherche<textarea name="research" rows="2"
>{usage.research}</textarea
></label
><label class="wide-field"
>Moderation<textarea name="moderation" rows="2"
>{usage.moderation}</textarea
></label
><label class="wide-field"
>Überleitung<textarea name="transition" rows="2"
>{usage.transition}</textarea
></label
><label class="wide-field"
>Präsentation<textarea name="presentation" rows="2"
>{usage.presentation}</textarea
></label
>
<input type="hidden" name="status" value={usage.status} /><button
class="primary-button wide-field"
type="submit">Verwendung speichern</button
>
</form>
<div class="usage-command-row">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="position"
value="9999"
/><label
>Ziel<select name="sectionId"
><option value="">Reservebank</option
>{#each data.show.sections as target (target.id)}<option
value={target.id}
selected={target.id === section.id}>{target.name}</option
>{/each}</select
></label
><button class="secondary-button" type="submit">Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
method="POST"
action="?/removeUsage"
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') &&
event.preventDefault()}
>
<input
type="hidden"
name="revision"
value={data.show.revision}
/><input type="hidden" name="usageId" value={usage.id} /><button
class="text-button danger-text"
type="submit">Entfernen</button
>
</form>
</div>
</details>
</div>
</div>
{/each}
@@ -811,6 +781,14 @@
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button
>
</form>
<UsageEditorDialog
{usage}
revision={data.show.revision}
sectionId=""
sections={data.show.sections}
error={form?.error}
{enhanceCommand}
/>
</div>
</div>
{/each}