517 lines
16 KiB
Svelte
517 lines
16 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, untrack } from 'svelte';
|
|
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
|
|
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 rendered = $state(untrack(() => ({ ...data.rendered })));
|
|
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;
|
|
let saving = $state(false);
|
|
let actionsBlocked = $derived(
|
|
saveState === 'dirty' ||
|
|
saveState === 'saving' ||
|
|
saveState === 'error' ||
|
|
saveState === 'conflict'
|
|
);
|
|
let pendingHref: string | null = null;
|
|
let allowNavigation = false;
|
|
|
|
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' &&
|
|
control.value !== 'text' &&
|
|
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 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 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;
|
|
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="page-wrap editor-page">
|
|
<header class="editor-header">
|
|
<div>
|
|
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
|
|
<h1>{draft.title}</h1>
|
|
</div>
|
|
<div
|
|
class:problem={saveState === 'error' || saveState === 'conflict'}
|
|
class="save-indicator"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<i></i><span>{saveMessage}</span>
|
|
</div>
|
|
</header>
|
|
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
|
|
|
<form
|
|
bind:this={editorForm}
|
|
use:enhance={enhanceSave}
|
|
method="POST"
|
|
action="?/save"
|
|
class="piece-editor"
|
|
oninput={scheduleSave}
|
|
onchange={scheduleSave}
|
|
>
|
|
<input type="hidden" name="revision" value={revision} />
|
|
<section class="editor-section editor-basics">
|
|
<div class="section-title">
|
|
<p class="eyebrow">Grundlagen</p>
|
|
<h2>Worum geht es?</h2>
|
|
</div>
|
|
<div class="editor-fields two-columns">
|
|
<label class="wide-field"
|
|
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
|
|
>
|
|
<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
|
|
>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
|
|
>Dauer in Minuten<input
|
|
name="durationMinutes"
|
|
type="number"
|
|
min="0"
|
|
max="1440"
|
|
step="any"
|
|
bind:value={durationMinutes}
|
|
/></label
|
|
>
|
|
<label class="wide-field"
|
|
>Kurzbeschreibung<textarea
|
|
name="summary"
|
|
rows="2"
|
|
maxlength="2000"
|
|
bind:value={draft.summary}></textarea></label
|
|
>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="editor-section taxonomy-editor">
|
|
<div class="section-title">
|
|
<p class="eyebrow">Einordnung</p>
|
|
<h2>Themen und Abschnitte</h2>
|
|
</div>
|
|
<fieldset>
|
|
<legend>Themen</legend>
|
|
<div class="choice-grid">
|
|
{#each data.taxonomy.topics as item (item.id)}<label
|
|
><input
|
|
type="checkbox"
|
|
name="topicIds"
|
|
value={item.id}
|
|
bind:group={draft.topicIds}
|
|
/>{item.name}</label
|
|
>{/each}
|
|
</div>
|
|
</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>
|
|
</section>
|
|
|
|
<section class="editor-section markdown-section">
|
|
<div class="section-title">
|
|
<p class="eyebrow">Redaktion</p>
|
|
<h2>Getrennte Arbeitsbereiche</h2>
|
|
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
|
|
</div>
|
|
<div class="markdown-grid">
|
|
<label
|
|
><span>Einleitung</span><textarea
|
|
name="intro"
|
|
rows="7"
|
|
maxlength="50000"
|
|
bind:value={draft.intro}></textarea>
|
|
<MarkdownPreview html={rendered.intro} /></label
|
|
>
|
|
<label class="private-field"
|
|
><span>Private Recherche</span><textarea
|
|
name="researchText"
|
|
rows="7"
|
|
maxlength="50000"
|
|
bind:value={draft.researchText}></textarea>
|
|
<MarkdownPreview html={rendered.research} /></label
|
|
>
|
|
<label
|
|
><span>Moderationsstichpunkte</span><textarea
|
|
name="moderationNotes"
|
|
rows="7"
|
|
maxlength="50000"
|
|
bind:value={draft.moderationNotes}></textarea>
|
|
<MarkdownPreview html={rendered.moderation} /></label
|
|
>
|
|
<label
|
|
><span>Überleitung</span><textarea
|
|
name="transitionText"
|
|
rows="7"
|
|
maxlength="50000"
|
|
bind:value={draft.transitionText}></textarea>
|
|
<MarkdownPreview html={rendered.transition} /></label
|
|
>
|
|
</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"
|
|
maxlength="50000"
|
|
bind:value={draft.presentationText}></textarea>
|
|
<MarkdownPreview html={rendered.presentation} /></label
|
|
>
|
|
{: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">
|
|
<div class="section-title split-section-title">
|
|
<div>
|
|
<p class="eyebrow">Belege</p>
|
|
<h2>Quellen</h2>
|
|
</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" bind:value={source.label} /></label
|
|
><label
|
|
>URL<input
|
|
name="sourceUrl"
|
|
type="url"
|
|
maxlength="2000"
|
|
bind:value={source.url}
|
|
/></label
|
|
><label
|
|
>Zeitmarke in Sekunden<input
|
|
name="sourceTimecode"
|
|
type="number"
|
|
min="0"
|
|
max="86400"
|
|
bind:value={source.timecodeSeconds}
|
|
/></label
|
|
><button
|
|
class="text-button danger-text"
|
|
type="button"
|
|
onclick={() => removeSource(index)}>Entfernen</button
|
|
>
|
|
</div>{/each}
|
|
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
|
|
</section>
|
|
|
|
<div class="editor-savebar">
|
|
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
|
|
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
|
|
>Jetzt speichern</button
|
|
>
|
|
</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">
|
|
<p class="eyebrow">Private Dateien</p>
|
|
<h2>Bilder</h2>
|
|
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
|
|
</div>
|
|
{#if data.piece.media.length}<div class="media-grid">
|
|
{#each data.piece.media as media (media.id)}<figure>
|
|
<img src={`/media/${media.id}`} alt={media.altText} />
|
|
<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
|
|
>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="editor-actions">
|
|
<form method="POST" action="?/duplicate">
|
|
<button class="secondary-button" type="submit" disabled={actionsBlocked}
|
|
>Piece duplizieren</button
|
|
>
|
|
</form>
|
|
<form method="POST" action="?/archive">
|
|
<input type="hidden" name="revision" value={revision} /><button
|
|
class="secondary-button danger-text"
|
|
type="submit"
|
|
disabled={actionsBlocked}>Piece archivieren</button
|
|
>
|
|
</form>
|
|
</section>
|
|
</div>
|