845 lines
27 KiB
Svelte
845 lines
27 KiB
Svelte
<script lang="ts">
|
|
import { enhance, type SubmitFunction } from '$app/forms';
|
|
import { beforeNavigate, goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import { onDestroy, onMount, untrack } from 'svelte';
|
|
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
|
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
|
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
|
|
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
|
|
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
|
|
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
|
|
import type { PresentationContent } from '#lib/server/presentation.js';
|
|
import type { PageProps } from './$types';
|
|
|
|
let { data, form }: PageProps = $props();
|
|
let draft = $state(untrack(() => ({ ...data.piece })));
|
|
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
|
let revision = $state(untrack(() => data.piece.revision));
|
|
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
|
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
|
|
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
|
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
|
let saveMessage = $state('Alle Änderungen gespeichert');
|
|
let sourceImageUnavailable = $state(false);
|
|
let editorDialog: { showModal: () => void; close: () => void };
|
|
let editorForm: HTMLFormElement;
|
|
let previewForm: HTMLFormElement;
|
|
let saveButton: HTMLButtonElement;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let generation = 0;
|
|
let saving = $state(false);
|
|
let actionsBlocked = $derived(
|
|
saveState === 'dirty' ||
|
|
saveState === 'saving' ||
|
|
saveState === 'error' ||
|
|
saveState === 'conflict'
|
|
);
|
|
let featuredSource = $derived(
|
|
draft.presentationSourcePosition === null
|
|
? (sources[0] ?? null)
|
|
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
|
|
);
|
|
let sourcePreviewImage = $derived(
|
|
draft.presentationImageUrl
|
|
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
|
|
: ''
|
|
);
|
|
let selectedMedia = $derived(
|
|
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
|
);
|
|
let previewContent = $derived<
|
|
PresentationContent & { images: { id: string; altText: string }[] }
|
|
>({
|
|
mode: draft.presentationMode,
|
|
topics: data.taxonomy.topics
|
|
.filter((topic) => draft.topicIds.includes(topic.id))
|
|
.map((topic) => topic.name),
|
|
title: draft.outputTitle || draft.title,
|
|
html: rendered.presentation,
|
|
source: featuredSource
|
|
? {
|
|
label: featuredSource.label || featuredSource.url,
|
|
url: featuredSource.url,
|
|
timecodeSeconds: featuredSource.timecodeSeconds
|
|
}
|
|
: null,
|
|
previewTitle: draft.presentationTitle,
|
|
description: draft.presentationDescription,
|
|
imageUrl: draft.presentationImageUrl,
|
|
siteName: draft.presentationSiteName,
|
|
provider: draft.presentationProvider as PresentationContent['provider'],
|
|
providerId: draft.presentationProviderId,
|
|
embedUrl:
|
|
draft.presentationProvider === 'youtube' && draft.presentationProviderId
|
|
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
|
|
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
|
|
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
|
|
: '',
|
|
images:
|
|
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
|
|
? [{ id: selectedMedia.id, altText: selectedMedia.altText }]
|
|
: []
|
|
});
|
|
let presentationPreviewImage = $derived(
|
|
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
|
|
? draft.presentationMode === 'text'
|
|
? '/media'
|
|
: `/media/${selectedMedia.id}`
|
|
: draft.presentationMode === 'link'
|
|
? sourcePreviewImage
|
|
: ''
|
|
);
|
|
let pendingHref: string | null = null;
|
|
let allowNavigation = false;
|
|
|
|
const statusLabels = {
|
|
inbox: 'Eingang',
|
|
preparing: 'In Vorbereitung',
|
|
ready: 'Sendefertig'
|
|
};
|
|
|
|
onMount(() => editorDialog.showModal());
|
|
onDestroy(() => clearTimeout(timer));
|
|
beforeNavigate((navigation) => {
|
|
if (allowNavigation) return;
|
|
if (saveState === 'error' || saveState === 'conflict') {
|
|
if (!confirm('Ungespeicherte Änderungen verwerfen und Seite verlassen?')) navigation.cancel();
|
|
return;
|
|
}
|
|
if (saveState !== 'dirty' && saveState !== 'saving') return;
|
|
navigation.cancel();
|
|
if (navigation.willUnload) return;
|
|
pendingHref = navigation.to?.url.href ?? null;
|
|
if (!saving) submitSave();
|
|
});
|
|
|
|
function scheduleSave(event?: Event) {
|
|
const control = event?.target;
|
|
if (event?.type === 'change' && !(control instanceof HTMLSelectElement)) return;
|
|
if (
|
|
control instanceof HTMLInputElement &&
|
|
control.name === 'presentationMode' &&
|
|
['link', 'video'].includes(control.value) &&
|
|
draft.presentationSourcePosition === null
|
|
) {
|
|
return;
|
|
}
|
|
generation += 1;
|
|
saveState = 'dirty';
|
|
saveMessage = 'Ungespeicherte Änderungen';
|
|
clearTimeout(timer);
|
|
timer = setTimeout(submitSave, 900);
|
|
}
|
|
|
|
function submitSave() {
|
|
if (saving) return;
|
|
editorForm.requestSubmit(saveButton);
|
|
}
|
|
|
|
function requestClose() {
|
|
const target = resolve('/fundus');
|
|
if (saveState === 'error' || saveState === 'conflict') {
|
|
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
|
|
allowNavigation = true;
|
|
void goto(target);
|
|
return;
|
|
}
|
|
if (saveState === 'dirty' || saveState === 'saving') {
|
|
pendingHref = new URL(target, window.location.href).href;
|
|
if (!saving) submitSave();
|
|
return;
|
|
}
|
|
allowNavigation = true;
|
|
void goto(target);
|
|
}
|
|
|
|
function addSource() {
|
|
sources.push({ label: '', url: '', timecodeSeconds: null });
|
|
scheduleSave();
|
|
}
|
|
|
|
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 removeTool(index: number) {
|
|
pieceTools.splice(index, 1);
|
|
scheduleSave();
|
|
}
|
|
|
|
function selectTools(ids: string[]) {
|
|
for (const id of ids) {
|
|
if (pieceTools.some((tool) => tool.toolId === id)) continue;
|
|
const tool = data.tools.find((item) => item.id === id);
|
|
if (tool)
|
|
pieceTools.push({
|
|
id: crypto.randomUUID(),
|
|
toolId: tool.id,
|
|
name: tool.name,
|
|
startUrl: tool.startUrl,
|
|
iconName: tool.iconName,
|
|
isActive: tool.isActive,
|
|
workUrl: '',
|
|
note: ''
|
|
});
|
|
}
|
|
pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId));
|
|
scheduleSave();
|
|
}
|
|
|
|
function presentationSourceChanged() {
|
|
draft.presentationMetadataFetchedAtUtc = null;
|
|
draft.presentationProvider = '';
|
|
draft.presentationProviderId = '';
|
|
sourceImageUnavailable = false;
|
|
if (!draft.presentationTitleManual) draft.presentationTitle = '';
|
|
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
|
|
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
|
|
}
|
|
|
|
const enhanceSave: SubmitFunction = () => {
|
|
const submittedGeneration = generation;
|
|
saving = true;
|
|
saveState = 'saving';
|
|
saveMessage = 'Speichert …';
|
|
return async ({ result }) => {
|
|
saving = false;
|
|
if (result.type === 'success') {
|
|
const response = result.data as { revision: number; rendered: typeof rendered };
|
|
revision = response.revision;
|
|
rendered = response.rendered;
|
|
if (generation === submittedGeneration) {
|
|
saveState = 'saved';
|
|
saveMessage = 'Gespeichert';
|
|
if (pendingHref) {
|
|
const target = pendingHref;
|
|
pendingHref = null;
|
|
allowNavigation = true;
|
|
// The target comes from SvelteKit's own NavigationTarget, never from user input.
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
await goto(new URL(target), { invalidateAll: true });
|
|
}
|
|
} else {
|
|
saveState = 'dirty';
|
|
saveMessage = 'Weitere Änderungen warten';
|
|
timer = setTimeout(submitSave, 250);
|
|
}
|
|
} else if (result.type === 'failure') {
|
|
const response = result.data as { error?: string; conflict?: boolean };
|
|
saveState = response.conflict ? 'conflict' : 'error';
|
|
saveMessage = response.error ?? 'Speichern fehlgeschlagen.';
|
|
} else {
|
|
saveState = 'error';
|
|
saveMessage = 'Speichern fehlgeschlagen.';
|
|
}
|
|
};
|
|
};
|
|
</script>
|
|
|
|
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
|
|
|
|
<div class="piece-editor-route">
|
|
{#if form?.error}<div class="form-message error route-editor-error" role="alert">
|
|
{form.error}
|
|
</div>{/if}
|
|
<PieceEditorDialog
|
|
bind:this={editorDialog}
|
|
bind:activeSection
|
|
labelledby="piece-editor-title"
|
|
onrequestclose={requestClose}
|
|
>
|
|
{#snippet header()}
|
|
<div class="piece-editor-kicker">
|
|
<p class="eyebrow">Fundusfassung</p>
|
|
<form method="POST" action="?/favorite" use:enhance>
|
|
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
|
|
<button
|
|
class="piece-favorite"
|
|
class:active={data.piece.isFavorite}
|
|
type="submit"
|
|
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
|
|
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
|
|
>
|
|
</form>
|
|
</div>
|
|
<InlineTitleEditor
|
|
form="piece-editor-form"
|
|
bind:value={draft.title}
|
|
oncommit={scheduleSave}
|
|
/>
|
|
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
|
|
{/snippet}
|
|
|
|
<form
|
|
bind:this={editorForm}
|
|
id="piece-editor-form"
|
|
use:enhance={enhanceSave}
|
|
method="POST"
|
|
action="?/save"
|
|
class="piece-dialog-form"
|
|
oninput={scheduleSave}
|
|
onchange={scheduleSave}
|
|
>
|
|
<input type="hidden" name="revision" value={revision} />
|
|
|
|
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
|
|
<div class="piece-panel-heading">
|
|
<div>
|
|
<p class="eyebrow">Für die Moderation</p>
|
|
<h2>Sendungstext</h2>
|
|
</div>
|
|
</div>
|
|
<div class="piece-writing-stack">
|
|
<MarkdownEditorField
|
|
name="intro"
|
|
label="Einleitung"
|
|
placeholder="Wie beginnt dieses Thema in der Sendung?"
|
|
bind:value={draft.intro}
|
|
/>
|
|
<MarkdownEditorField
|
|
name="moderationNotes"
|
|
label="Moderationsstichpunkte"
|
|
placeholder="Argumente, Fragen und Ablaufpunkte"
|
|
rows={10}
|
|
bind:value={draft.moderationNotes}
|
|
/>
|
|
<MarkdownEditorField
|
|
name="interactionText"
|
|
label="Interaktion"
|
|
placeholder="Welche Frage möchtest du dem Chat stellen?"
|
|
privateField
|
|
bind:value={draft.interactionText}
|
|
/>
|
|
<MarkdownEditorField
|
|
name="transitionText"
|
|
label="Überleitung"
|
|
placeholder="Wie geht es zum nächsten Thema weiter?"
|
|
bind:value={draft.transitionText}
|
|
/>
|
|
</div>
|
|
<details class="piece-meta-details">
|
|
<summary>
|
|
<span>Status, Dauer und Einordnung</span>
|
|
<small
|
|
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
|
|
>
|
|
</summary>
|
|
<div class="piece-meta-grid">
|
|
<label
|
|
>Status<select name="status" bind:value={draft.status}
|
|
><option value="inbox">Eingang</option><option value="preparing"
|
|
>In Vorbereitung</option
|
|
><option value="ready">Sendefertig</option></select
|
|
></label
|
|
>
|
|
<label
|
|
>Dauer in Minuten<input
|
|
name="durationMinutes"
|
|
type="number"
|
|
min="0"
|
|
max="1440"
|
|
step="any"
|
|
bind:value={durationMinutes}
|
|
/></label
|
|
>
|
|
<label
|
|
>Vorbereitungsaufwand<select
|
|
name="preparationEffort"
|
|
bind:value={draft.preparationEffort}
|
|
><option value={null}>Nicht angegeben</option><option value="low">Gering</option
|
|
><option value="medium">Mittel</option><option value="high">Hoch</option></select
|
|
></label
|
|
>
|
|
<label
|
|
>Format<select name="formatId" bind:value={draft.formatId}
|
|
><option value={null}>Ohne Format</option
|
|
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
|
|
>{item.name}</option
|
|
>{/each}</select
|
|
></label
|
|
>
|
|
<label class="wide-field"
|
|
>Kurzbeschreibung<textarea
|
|
name="summary"
|
|
rows="3"
|
|
maxlength="2000"
|
|
placeholder="Worum geht es in wenigen Sätzen?"
|
|
bind:value={draft.summary}></textarea></label
|
|
>
|
|
<fieldset>
|
|
<legend>Themen</legend><ChoicePicker
|
|
items={data.taxonomy.topics}
|
|
selectedIds={draft.topicIds}
|
|
label="Themen"
|
|
fieldName="topicIds"
|
|
onselectionchange={(ids) => {
|
|
draft.topicIds = ids;
|
|
scheduleSave();
|
|
}}
|
|
/>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
|
<div class="choice-grid">
|
|
{#each data.taxonomy.categories as item (item.id)}<label
|
|
><input
|
|
type="checkbox"
|
|
name="categoryIds"
|
|
value={item.id}
|
|
bind:group={draft.categoryIds}
|
|
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
|
|
>{/each}
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
|
|
<div class="piece-panel-heading">
|
|
<div>
|
|
<p class="eyebrow">Nicht öffentlich</p>
|
|
<h2>Recherche & Quellen</h2>
|
|
</div>
|
|
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
|
|
</div>
|
|
<MarkdownEditorField
|
|
name="researchText"
|
|
label="Private Recherche"
|
|
placeholder="Hintergrund, Prüfung und offene Fragen"
|
|
rows={10}
|
|
privateField
|
|
bind:value={draft.researchText}
|
|
/>
|
|
<section class="piece-source-editor">
|
|
<div class="piece-subsection-heading">
|
|
<div>
|
|
<p class="eyebrow">Fundstellen</p>
|
|
<h3>Quellen</h3>
|
|
</div>
|
|
<button class="secondary-button" type="button" onclick={addSource}
|
|
>Quelle hinzufügen</button
|
|
>
|
|
</div>
|
|
{#if sources.length}<div class="source-list">
|
|
{#each sources as source, index (index)}<div class="source-row">
|
|
<label
|
|
>Name<input
|
|
name="sourceLabel"
|
|
maxlength="200"
|
|
placeholder="Name der Quelle"
|
|
bind:value={source.label}
|
|
/></label
|
|
><label
|
|
>URL<input
|
|
name="sourceUrl"
|
|
type="url"
|
|
maxlength="2000"
|
|
placeholder="https://…"
|
|
bind:value={source.url}
|
|
/></label
|
|
><label
|
|
>Zeitmarke in Sekunden<input
|
|
name="sourceTimecode"
|
|
type="number"
|
|
min="0"
|
|
max="86400"
|
|
placeholder="Optional"
|
|
bind:value={source.timecodeSeconds}
|
|
/></label
|
|
><button
|
|
class="text-button danger-text"
|
|
type="button"
|
|
onclick={() => removeSource(index)}>Entfernen</button
|
|
>
|
|
</div>{/each}
|
|
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
|
|
</section>
|
|
<section class="piece-source-editor">
|
|
<div class="piece-subsection-heading">
|
|
<div>
|
|
<p class="eyebrow">Arbeitsmittel</p>
|
|
<h3>Werkzeuge</h3>
|
|
</div>
|
|
<ChoicePicker
|
|
items={data.tools
|
|
.filter((tool) => tool.isActive)
|
|
.map((tool) => ({
|
|
id: tool.id,
|
|
name: tool.name,
|
|
description: tool.description,
|
|
iconName: tool.iconName,
|
|
meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : ''
|
|
}))}
|
|
selectedIds={pieceTools.map((tool) => tool.toolId)}
|
|
label="Werkzeuge"
|
|
onselectionchange={selectTools}
|
|
/>
|
|
</div>
|
|
{#if pieceTools.length}<div class="source-list">
|
|
{#each pieceTools as tool, index (tool.id)}<div
|
|
class="source-row tool-assignment-row"
|
|
>
|
|
<input type="hidden" name="toolId" value={tool.toolId} />
|
|
<div class="tool-assignment-name">
|
|
<AppIcon name={tool.iconName || 'wrench'} /><span
|
|
><strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small
|
|
>{/if}</span
|
|
>
|
|
</div>
|
|
<label
|
|
>Arbeitslink<input
|
|
name="toolWorkUrl"
|
|
type="url"
|
|
maxlength="2000"
|
|
placeholder={tool.startUrl || 'https://…'}
|
|
bind:value={tool.workUrl}
|
|
/></label
|
|
>
|
|
<label
|
|
>Notiz<input
|
|
name="toolNote"
|
|
maxlength="2000"
|
|
placeholder="Optional"
|
|
bind:value={tool.note}
|
|
/></label
|
|
>
|
|
<button
|
|
class="text-button danger-text"
|
|
type="button"
|
|
onclick={() => removeTool(index)}>Entfernen</button
|
|
>
|
|
</div>{/each}
|
|
</div>{:else}<p class="context-empty">Noch kein Werkzeug zugeordnet.</p>{/if}
|
|
</section>
|
|
</section>
|
|
|
|
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
|
<div class="piece-panel-heading">
|
|
<div>
|
|
<p class="eyebrow">Öffentliche Ausgabe</p>
|
|
<h2>Präsentation</h2>
|
|
</div>
|
|
<a
|
|
class="secondary-button"
|
|
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
|
|
target="_blank"
|
|
rel="noopener">In neuem Fenster testen</a
|
|
>
|
|
</div>
|
|
<div class="piece-presentation-layout">
|
|
<div class="piece-presentation-controls">
|
|
<p class="field-label">Präsentationsart</p>
|
|
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
|
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
|
|
><input
|
|
type="radio"
|
|
name="presentationMode"
|
|
value={mode[0]}
|
|
bind:group={draft.presentationMode}
|
|
/>{mode[1]}</label
|
|
>{/each}
|
|
</div>
|
|
{#if draft.presentationMode === 'text'}
|
|
<label
|
|
>Öffentliche Überschrift<input
|
|
name="outputTitle"
|
|
maxlength="300"
|
|
placeholder="Optional, sonst interner Titel"
|
|
bind:value={draft.outputTitle}
|
|
/></label
|
|
>
|
|
<MarkdownEditorField
|
|
name="presentationText"
|
|
label="Öffentlicher Vorspann"
|
|
placeholder="Text für die Publikumsausgabe"
|
|
rows={8}
|
|
bind:value={draft.presentationText}
|
|
/>
|
|
{:else}
|
|
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
|
|
<input type="hidden" name="presentationText" value={draft.presentationText} />
|
|
{/if}
|
|
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
|
|
<div class="presentation-settings">
|
|
<label
|
|
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<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
|
|
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
|
|
name="presentationTitle"
|
|
maxlength="300"
|
|
placeholder="Optionaler Anzeigetitel"
|
|
bind:value={draft.presentationTitle}
|
|
oninput={() => (draft.presentationTitleManual = true)}
|
|
/></label
|
|
>
|
|
{#if draft.presentationMode === 'link'}
|
|
<label
|
|
>Vorschaubild-URL<input
|
|
name="presentationImageUrl"
|
|
type="url"
|
|
maxlength="2000"
|
|
placeholder="Wird aus der Linkvorschau geladen"
|
|
bind:value={draft.presentationImageUrl}
|
|
oninput={() => (draft.presentationImageManual = true)}
|
|
/></label
|
|
>
|
|
{:else}
|
|
<input
|
|
type="hidden"
|
|
name="presentationImageUrl"
|
|
value={draft.presentationImageUrl}
|
|
/>
|
|
{/if}
|
|
<label class="wide-field"
|
|
>Anreißer<textarea
|
|
name="presentationDescription"
|
|
rows="3"
|
|
maxlength="1000"
|
|
placeholder="Kurzer Text zur Vorschau"
|
|
bind:value={draft.presentationDescription}
|
|
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
|
|
>
|
|
<div class="presentation-preview-actions wide-field">
|
|
<button
|
|
class="secondary-button"
|
|
type="button"
|
|
onclick={() => previewForm.requestSubmit()}
|
|
disabled={actionsBlocked || draft.presentationSourcePosition === null}
|
|
>Vorschau laden</button
|
|
><small
|
|
>{draft.presentationMetadataFetchedAtUtc
|
|
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
|
|
: 'Noch keine Metadaten geladen.'}</small
|
|
>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<input
|
|
type="hidden"
|
|
name="presentationSourcePosition"
|
|
value={draft.presentationSourcePosition ?? ''}
|
|
/>
|
|
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
|
|
<input
|
|
type="hidden"
|
|
name="presentationDescription"
|
|
value={draft.presentationDescription}
|
|
/>
|
|
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
|
{/if}
|
|
</div>
|
|
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
|
<PresentationDisplay
|
|
content={previewContent}
|
|
imageSrc={sourceImageUnavailable ? '' : presentationPreviewImage}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
<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>
|
|
</form>
|
|
|
|
<section
|
|
class="piece-editor-panel piece-media-panel"
|
|
hidden={activeSection !== 'presentation' ||
|
|
!['text', 'image'].includes(draft.presentationMode)}
|
|
>
|
|
<div class="piece-subsection-heading">
|
|
<div>
|
|
<p class="eyebrow">Hochgeladene Bilder</p>
|
|
<h3>Präsentationsbild</h3>
|
|
</div>
|
|
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
|
|
</div>
|
|
{#if data.piece.media.length}<label class="media-none"
|
|
><input
|
|
form="piece-editor-form"
|
|
type="radio"
|
|
name="presentationMediaId"
|
|
value=""
|
|
checked={!draft.presentationMediaId}
|
|
onchange={() => {
|
|
draft.presentationMediaId = null;
|
|
scheduleSave();
|
|
}}
|
|
/> Kein hochgeladenes Bild</label
|
|
>
|
|
<div class="media-grid">
|
|
{#each data.piece.media as media (media.id)}<figure>
|
|
<label class="media-presentation-choice"
|
|
><input
|
|
form="piece-editor-form"
|
|
type="radio"
|
|
name="presentationMediaId"
|
|
value={media.id}
|
|
checked={draft.presentationMediaId === media.id}
|
|
onchange={() => {
|
|
draft.presentationMediaId = media.id;
|
|
scheduleSave();
|
|
}}
|
|
/><img src={`/media/${media.id}`} alt={media.altText} /><span
|
|
>Als Präsentationsbild</span
|
|
></label
|
|
>
|
|
<figcaption>
|
|
<span>{media.altText || media.originalName}</span>
|
|
<form method="POST" action="?/deleteImage">
|
|
<input type="hidden" name="revision" value={revision} /><input
|
|
type="hidden"
|
|
name="mediaId"
|
|
value={media.id}
|
|
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
|
|
>Entfernen</button
|
|
>
|
|
</form>
|
|
</figcaption>
|
|
</figure>{/each}
|
|
</div>{/if}
|
|
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
|
|
<input type="hidden" name="revision" value={revision} /><label
|
|
>Bild<input
|
|
name="image"
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
required
|
|
/></label
|
|
><label
|
|
>Alternativtext<input
|
|
name="altText"
|
|
maxlength="300"
|
|
placeholder="Was ist auf dem Bild zu sehen?"
|
|
/></label
|
|
><button class="secondary-button" type="submit" disabled={actionsBlocked}
|
|
>Bild hochladen</button
|
|
>
|
|
<label class="upload-select"
|
|
><input type="checkbox" name="selectForPresentation" value="1" checked /> Direkt als Präsentationsbild
|
|
verwenden</label
|
|
>
|
|
</form>
|
|
</section>
|
|
|
|
{#snippet footer()}
|
|
<div class="piece-dialog-secondary-actions">
|
|
<details class="template-save-menu">
|
|
<summary>Als Vorlage speichern</summary>
|
|
<form method="POST" action="?/saveTemplate">
|
|
<label
|
|
>Vorlage<select name="templateRef">
|
|
<option value="">Neue Vorlage</option>
|
|
{#each data.templates as template (template.id)}<option
|
|
value={`${template.id}|${template.revision}`}>{template.name}</option
|
|
>{/each}
|
|
</select></label
|
|
>
|
|
<label>Name<input name="templateName" required maxlength="120" /></label>
|
|
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
|
|
<button class="text-button" type="submit" disabled={actionsBlocked}
|
|
>Vorlage speichern</button
|
|
>
|
|
</form>
|
|
</details>
|
|
<form method="POST" action="?/duplicate">
|
|
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
|
|
</form>
|
|
<form method="POST" action="?/archive">
|
|
<input type="hidden" name="revision" value={revision} /><button
|
|
class="text-button danger-text"
|
|
type="submit"
|
|
aria-label="Piece archivieren"
|
|
disabled={actionsBlocked}>Archivieren</button
|
|
>
|
|
</form>
|
|
<form
|
|
method="POST"
|
|
action="?/delete"
|
|
onsubmit={(event) =>
|
|
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
|
|
event.preventDefault()}
|
|
>
|
|
<input type="hidden" name="revision" value={revision} /><button
|
|
class="text-button danger-text"
|
|
type="submit"
|
|
disabled={actionsBlocked}>Piece löschen</button
|
|
>
|
|
</form>
|
|
</div>
|
|
<div class="piece-dialog-primary-action">
|
|
<span
|
|
class="save-indicator"
|
|
class:problem={saveState === 'error' || saveState === 'conflict'}
|
|
role="status"
|
|
aria-live="polite">{saveMessage}</span
|
|
>
|
|
<button
|
|
bind:this={saveButton}
|
|
class:primary-button={saveState === 'dirty' ||
|
|
saveState === 'error' ||
|
|
saveState === 'conflict'}
|
|
class:secondary-button={saveState === 'idle' ||
|
|
saveState === 'saved' ||
|
|
saveState === 'saving'}
|
|
type="submit"
|
|
form="piece-editor-form"
|
|
disabled={saving}
|
|
>{saveState === 'error' || saveState === 'conflict'
|
|
? 'Erneut speichern'
|
|
: 'Speichern'}</button
|
|
>
|
|
</div>
|
|
{/snippet}
|
|
</PieceEditorDialog>
|
|
|
|
<form
|
|
bind:this={previewForm}
|
|
id="preview-load-form"
|
|
method="POST"
|
|
action="?/loadPreview"
|
|
use:enhance
|
|
>
|
|
<input type="hidden" name="revision" value={revision} />
|
|
</form>
|
|
</div>
|