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

+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}