Release 0.5.2

This commit is contained in:
hnzio committed 2026-10-22 13:51:30 +02:00
1 parent fb24d171ae
commit 25da870601
10 files changed
+1308 -298

No files matched your search

+585 -1
View File
@@ -2544,6 +2544,467 @@ a {
margin-top: 0.75rem;
}
.worksheet-page {
max-width: 94rem;
}
.worksheet-editor {
gap: 1rem;
}
.worksheet-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1.5rem;
}
.worksheet-heading {
min-width: 0;
flex: 1;
}
.worksheet-title {
display: block;
margin-top: 0.3rem;
}
.worksheet-title textarea {
width: 100%;
height: 1.2em;
padding: 0;
resize: none;
overflow: hidden;
border: 0;
outline-offset: 0.3rem;
background: transparent;
color: var(--text);
font: inherit;
font-size: clamp(1.65rem, 3.3vw, 2.8rem);
font-weight: 760;
letter-spacing: -0.045em;
}
.editor-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(19rem, 23rem);
align-items: start;
gap: 1rem;
}
.editorial-worksheet {
display: grid;
min-width: 0;
gap: 0.8rem;
}
.source-feature {
display: grid;
min-height: 8.5rem;
align-items: stretch;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
}
.source-feature.has-image {
grid-template-columns: minmax(9rem, 0.32fr) minmax(0, 1fr);
}
.source-feature img {
width: 100%;
height: 100%;
min-height: 8.5rem;
object-fit: cover;
}
.source-feature > div {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 1rem 1.15rem;
}
.source-feature h2,
.writing-sheet h2,
.context-card h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.source-feature p:not(.eyebrow) {
margin: 0.3rem 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.source-feature small {
max-width: 100%;
margin-top: 0.2rem;
overflow-wrap: anywhere;
color: var(--text-faint);
font-size: 0.66rem;
}
.source-feature a,
.source-feature .text-button {
margin-top: 0.65rem;
color: var(--accent);
font-size: 0.72rem;
font-weight: 750;
}
.writing-sheet {
padding: clamp(1rem, 2vw, 1.6rem);
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
}
.writing-sheet-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.writing-sheet-heading small,
.script-heading > p:last-child {
color: var(--text-faint);
font-size: 0.67rem;
}
.editorial-field {
padding: 1rem 0;
border-bottom: 1px solid var(--line);
}
.editorial-field.private-field {
margin: 0.85rem 0 0;
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--gold) 38%, var(--line));
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
}
.editorial-field > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.editorial-field h3 {
margin: 0;
font-size: 0.9rem;
}
.editorial-field header p {
margin: 0.18rem 0 0;
color: var(--gold-strong);
font-size: 0.63rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.editorial-edit-button {
flex: 0 0 auto;
color: var(--accent);
}
.editorial-input {
display: block;
margin-top: 0.65rem;
}
.editorial-input textarea {
width: 100%;
min-height: 9rem;
padding: 0.8rem;
resize: vertical;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
font: inherit;
font-size: 0.82rem;
line-height: 1.6;
}
.editorial-field > .markdown-preview {
min-height: 0;
margin-top: 0.55rem;
padding: 0;
border: 0;
color: var(--text);
font-size: 0.82rem;
}
.editorial-empty {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.5rem;
padding: 0.6rem 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text-muted);
text-align: left;
}
.editorial-empty:hover {
color: var(--text);
}
.editorial-empty small {
flex: 0 0 auto;
color: var(--accent);
font-size: 0.64rem;
font-weight: 750;
}
.script-heading {
padding: 1.4rem 0 0.3rem;
}
.script-heading > p:last-child {
margin: 0.25rem 0 0;
}
.script-sections .editorial-field:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.editor-context {
position: sticky;
top: 1rem;
display: grid;
max-height: calc(100vh - 2rem);
gap: 0.7rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.context-card {
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--panel) 96%, transparent);
}
.context-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.7rem;
}
.context-close,
.mobile-editor-tools {
display: none;
}
.context-empty,
.context-summary-copy,
.asset-summary p,
.audience-preview p {
margin: 0.4rem 0;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.5;
}
.context-wide-action {
width: 100%;
margin-top: 0.65rem;
}
.context-card .source-list {
gap: 0.7rem;
}
.context-card .source-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.65fr);
gap: 0.4rem;
padding: 0 0 0.7rem;
}
.context-card .source-row label:nth-child(2) {
grid-column: 1 / -1;
grid-row: 2;
}
.context-card .source-row label:nth-child(3) {
grid-column: 2;
grid-row: 1;
}
.context-card .source-row > button {
grid-column: 1 / -1;
}
.context-card .source-row label,
.context-fields > label,
.presentation-settings > label {
display: grid;
gap: 0.22rem;
color: var(--text-muted);
font-size: 0.66rem;
font-weight: 700;
}
.context-card input:not([type='checkbox'], [type='radio']),
.context-card select,
.context-card textarea {
width: 100%;
min-width: 0;
padding: 0.5rem 0.58rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
}
.classification-summary,
.context-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.classification-summary > *,
.context-tags span {
padding: 0.3rem 0.48rem;
border-radius: 2rem;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.64rem;
}
.classification-summary strong {
background: var(--accent-soft);
color: var(--accent);
}
.context-edit-toggle {
margin-top: 0.65rem;
color: var(--accent);
}
.context-fields,
.presentation-controls {
display: grid;
gap: 0.7rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
.context-fields[hidden],
.presentation-controls[hidden] {
display: none;
}
.context-fields fieldset {
margin: 0;
padding: 0;
border: 0;
}
.context-fields legend {
margin-bottom: 0.35rem;
color: var(--text-muted);
font-size: 0.66rem;
font-weight: 700;
}
.context-fields .choice-grid label {
padding: 0.3rem 0.45rem;
font-size: 0.64rem;
}
.audience-preview {
padding: 0.7rem;
border-radius: var(--radius-small);
background: var(--panel-soft);
}
.audience-preview > span {
color: var(--accent);
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.audience-preview > strong {
display: block;
margin-top: 0.3rem;
font-size: 0.82rem;
}
.audience-preview .markdown-preview {
min-height: 0;
margin-top: 0.4rem;
padding: 0;
border: 0;
font-size: 0.7rem;
}
.presentation-card-actions {
display: grid;
gap: 0.45rem;
margin-top: 0.65rem;
}
.presentation-card-actions .text-button {
justify-self: start;
color: var(--accent);
}
.presentation-card-actions .secondary-button {
width: 100%;
text-align: center;
}
.presentation-controls .editorial-field {
padding: 0.2rem 0 0;
border: 0;
}
.presentation-controls .presentation-settings {
grid-template-columns: 1fr;
margin-top: 0;
}
.presentation-controls .presentation-preview-actions {
align-items: stretch;
flex-direction: column;
}
.asset-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.supporting-editor-section {
max-width: calc(100% - 24rem);
}
.shows-page {
max-width: 78rem;
}
@@ -3272,6 +3733,12 @@ a {
.source-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-workspace {
grid-template-columns: minmax(0, 1fr) minmax(17rem, 20rem);
}
.supporting-editor-section {
max-width: calc(100% - 21rem);
}
.show-create-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -3413,12 +3880,129 @@ a {
align-items: flex-start;
flex-direction: column;
}
.worksheet-header {
align-items: flex-start;
flex-direction: column;
gap: 0.6rem;
}
.worksheet-title textarea {
height: 2.3em;
font-size: 1.65rem;
line-height: 1.12;
}
.mobile-editor-tools {
position: sticky;
top: calc(var(--mobile-header) + 0.45rem);
z-index: 12;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.3rem;
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
}
.mobile-editor-tools button,
.mobile-editor-tools a {
min-width: 0;
padding: 0.55rem 0.3rem;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--text-muted);
font-size: 0.65rem;
font-weight: 750;
text-align: center;
}
.mobile-editor-tools button[aria-expanded='true'] {
background: var(--accent-soft);
color: var(--accent);
}
.editor-workspace {
display: block;
}
.source-feature.has-image {
grid-template-columns: 6.5rem minmax(0, 1fr);
}
.source-feature,
.source-feature img {
min-height: 7.5rem;
}
.source-feature > div {
padding: 0.85rem;
}
.writing-sheet {
margin-top: 0.7rem;
padding: 0.9rem;
border-radius: var(--radius-medium);
}
.writing-sheet-heading {
align-items: flex-start;
flex-direction: column;
gap: 0.35rem;
}
.editorial-field.private-field {
padding: 0.75rem;
}
.editorial-empty {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
.editor-context {
position: static;
max-height: none;
overflow: visible;
}
.editor-context.context-open::before {
position: fixed;
inset: 0;
z-index: 38;
background: color-mix(in srgb, var(--text) 28%, transparent);
content: '';
}
.editor-context .context-card {
display: none;
}
.editor-context .context-card.mobile-active {
position: fixed;
inset: calc(var(--mobile-header) + 0.5rem) 0.5rem calc(var(--mobile-nav) + 0.5rem);
z-index: 40;
display: block;
overflow-y: auto;
padding: 1rem;
border-radius: var(--radius-large);
background: var(--panel-strong);
box-shadow: var(--shadow);
overscroll-behavior: contain;
}
.context-close {
display: inline-grid;
width: 2rem;
height: 2rem;
place-items: center;
padding: 0;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--panel-soft);
color: var(--text);
font-size: 1.2rem;
}
.supporting-editor-section {
max-width: none;
scroll-margin-top: calc(var(--mobile-header) + 4.5rem);
}
.worksheet-editor:has(.editor-context.context-open) .editor-savebar {
visibility: hidden;
}
.editor-section {
padding: 0.85rem;
border-radius: var(--radius-medium);
}
.editor-savebar {
bottom: calc(var(--mobile-nav) + 0.6rem);
position: static;
justify-content: space-between;
}
.split-section-title,
@@ -0,0 +1,60 @@
<script lang="ts">
import MarkdownPreview from './MarkdownPreview.svelte';
let {
name,
label,
inputLabel = label,
hint = '',
emptyText,
value = $bindable(),
html,
privateField = false,
rows = 7
}: {
name: string;
label: string;
inputLabel?: string;
hint?: string;
emptyText: string;
value: string;
html: string;
privateField?: boolean;
rows?: number;
} = $props();
let editing = $state(false);
</script>
<section class:private-field={privateField} class:empty={!value.trim()} class="editorial-field">
<header>
<div>
<h3>{label}</h3>
{#if hint}<p>{hint}</p>{/if}
</div>
<button
class="text-button editorial-edit-button"
type="button"
aria-expanded={editing}
onclick={() => (editing = !editing)}
>
{editing ? 'Lesen' : value.trim() ? 'Bearbeiten' : 'Schreiben'}
</button>
</header>
{#if editing}
<label class="editorial-input">
<span class="sr-only">{inputLabel}</span>
<textarea aria-label={inputLabel} {name} {rows} maxlength="50000" bind:value></textarea>
</label>
{:else}
<input type="hidden" {name} {value} />
{#if value.trim()}
<MarkdownPreview {html} />
{:else}
<button class="editorial-empty" type="button" onclick={() => (editing = true)}>
<span>{emptyText}</span><small>Zum Schreiben öffnen</small>
</button>
{/if}
{/if}
</section>
+3 -2
View File
@@ -12,9 +12,9 @@ export const actions = {
capture: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
@@ -26,5 +26,6 @@ export const actions = {
: 'Ein Titel ist erforderlich.'
});
}
redirect(303, `/fundus/${id}`);
}
} satisfies Actions;
+519 -292
View File
@@ -2,10 +2,13 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import { onDestroy, tick, untrack } from 'svelte';
import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import type { PageProps } from './$types';
type ContextPanel = 'sources' | 'classification' | 'presentation';
let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece })));
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
@@ -14,6 +17,10 @@
let rendered = $state(untrack(() => ({ ...data.rendered })));
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let classificationEditing = $state(false);
let presentationEditing = $state(false);
let mobileContext = $state<ContextPanel | null>(null);
let sourceImageUnavailable = $state(false);
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
@@ -26,8 +33,27 @@
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 })
: data.piece.media[0]
? `/media/${data.piece.media[0].id}`
: ''
);
let pendingHref: string | null = null;
let allowNavigation = false;
let contextTrigger: HTMLElement | null = null;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
@@ -87,11 +113,29 @@
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
}
async function openContext(event: MouseEvent, panel: ContextPanel) {
contextTrigger = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
mobileContext = panel;
await tick();
document.querySelector<HTMLElement>(`[data-context-close="${panel}"]`)?.focus();
}
function closeContext() {
mobileContext = null;
contextTrigger?.focus();
contextTrigger = null;
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && mobileContext) closeContext();
}
const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation;
saving = true;
@@ -132,22 +176,9 @@
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:window onkeydown={handleEscape} />
<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>
<div class="page-wrap editor-page worksheet-page">
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
@@ -155,297 +186,493 @@
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
class="piece-editor worksheet-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>
<header class="worksheet-header">
<div class="worksheet-heading">
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1 class="sr-only">{draft.title}</h1>
<label class="worksheet-title">
<span class="sr-only">Titel</span>
<textarea
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}></textarea>
</label>
</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
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</section>
</header>
<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>
<nav class="mobile-editor-tools" aria-label="Piece-Werkzeuge">
<button
type="button"
aria-expanded={mobileContext === 'sources'}
onclick={(event) => openContext(event, 'sources')}>Quellen</button
>
<button
type="button"
aria-expanded={mobileContext === 'classification'}
onclick={(event) => openContext(event, 'classification')}>Einordnung</button
>
<button
type="button"
aria-expanded={mobileContext === 'presentation'}
onclick={(event) => openContext(event, 'presentation')}>Präsentation</button
>
<a href="#piece-media">Bilder</a>
</nav>
<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
<div class="editor-workspace">
<main class="editorial-worksheet" aria-label="Redaktionelles Arbeitsblatt">
<section
class:has-image={sourcePreviewImage && !sourceImageUnavailable}
class="source-feature"
>
<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
{#if sourcePreviewImage && !sourceImageUnavailable}
<img src={sourcePreviewImage} alt="" onerror={() => (sourceImageUnavailable = true)} />
{/if}
<div>
<p class="eyebrow">Fundstück</p>
{#if featuredSource}
<h2>{draft.presentationTitle || featuredSource.label || 'Quelle'}</h2>
{#if draft.presentationDescription}<p>{draft.presentationDescription}</p>{/if}
<small>{featuredSource.label || featuredSource.url}</small>
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={featuredSource.url} target="_blank" rel="noopener noreferrer"
>Original öffnen ↗</a
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
</section>
{:else}
<h2>Noch keine Quelle</h2>
<p>Eine Quelle lässt sich ergänzen, ohne den Schreibstand zu verlassen.</p>
<button
class="text-button"
type="button"
onclick={(event) => openContext(event, 'sources')}>Quelle ergänzen</button
>
{/if}
</div>
</section>
<section class="writing-sheet">
<header class="writing-sheet-heading">
<div>
<p class="eyebrow">Arbeitsstand</p>
<h2>Gedanken und Sendungstext</h2>
</div>
<small>Markdown wird beim Speichern sicher formatiert.</small>
</header>
<EditorialMarkdownField
name="researchText"
label="Eigene Gedanken & Recherche"
inputLabel="Private Recherche"
hint="Nur intern"
emptyText="Beobachtungen, Links und offene Fragen festhalten"
bind:value={draft.researchText}
html={rendered.research}
privateField
rows={9}
/>
<div class="script-heading">
<p class="eyebrow">Für die Sendung</p>
<h2>Sendungstext</h2>
<p>Einleitung, Moderation und Übergang bleiben getrennt gespeichert.</p>
</div>
<div class="script-sections">
<EditorialMarkdownField
name="intro"
label="Einleitung"
emptyText="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
html={rendered.intro}
/>
<EditorialMarkdownField
name="moderationNotes"
label="Moderationsstichpunkte"
emptyText="Die wichtigsten Punkte für die Moderation notieren"
bind:value={draft.moderationNotes}
html={rendered.moderation}
/>
<EditorialMarkdownField
name="transitionText"
label="Überleitung"
emptyText="Den Übergang zum nächsten Thema vorbereiten"
bind:value={draft.transitionText}
html={rendered.transition}
/>
</div>
</section>
</main>
<aside
class:context-open={mobileContext}
class="editor-context"
aria-label="Werkzeuge zum Piece"
>
<section
class:mobile-active={mobileContext === 'sources'}
class="context-card source-editor"
aria-labelledby="source-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Fundstück</p>
<h2 id="source-context-title">Quellen</h2>
</div>
<button
class="context-close"
data-context-close="sources"
type="button"
aria-label="Quellen schließen"
onclick={closeContext}>×</button
>
</header>
{#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="context-empty">Noch keine Quelle hinterlegt.</p>
{/if}
<button class="secondary-button context-wide-action" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</section>
<section
class:mobile-active={mobileContext === 'classification'}
class="context-card classification-editor"
aria-labelledby="classification-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Überblick</p>
<h2 id="classification-context-title">Einordnung</h2>
</div>
<button
class="context-close"
data-context-close="classification"
type="button"
aria-label="Einordnung schließen"
onclick={closeContext}>×</button
>
</header>
<div class="classification-summary">
<strong>{statusLabels[draft.status as keyof typeof statusLabels]}</strong>
<span>{durationMinutes || 0} Min.</span>
<span
>{data.taxonomy.formats.find((item) => item.id === draft.formatId)?.name ??
'Ohne Format'}</span
>
</div>
{#if draft.summary}<p class="context-summary-copy">{draft.summary}</p>{/if}
<div class="context-tags">
{#each data.taxonomy.topics.filter( (item) => draft.topicIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
{#each data.taxonomy.categories.filter( (item) => draft.categoryIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
</div>
<button
class="text-button context-edit-toggle"
type="button"
aria-expanded={classificationEditing}
onclick={() => (classificationEditing = !classificationEditing)}
>{classificationEditing ? 'Einordnung schließen' : 'Einordnung bearbeiten'}</button
>
<div class="context-fields" hidden={!classificationEditing}>
<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
>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
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
<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>
</div>
</section>
<section
class:mobile-active={mobileContext === 'presentation'}
class="context-card presentation-editor"
aria-labelledby="presentation-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Publikum</p>
<h2 id="presentation-context-title">Präsentation</h2>
</div>
<button
class="context-close"
data-context-close="presentation"
type="button"
aria-label="Präsentation schließen"
onclick={closeContext}>×</button
>
</header>
<div class="audience-preview">
<span
>{draft.presentationMode === 'text'
? 'Text'
: draft.presentationMode === 'link'
? 'Linkvorschau'
: 'Video'}</span
>
<strong>{draft.presentationTitle || draft.title}</strong>
{#if draft.presentationMode === 'text' && rendered.presentation}<MarkdownPreview
html={rendered.presentation}
/>{:else if draft.presentationDescription}<p>
{draft.presentationDescription}
</p>{:else}<p>Noch keine Vorschauangaben.</p>{/if}
</div>
<div class="presentation-card-actions">
<button
class="text-button"
type="button"
aria-expanded={presentationEditing}
onclick={() => (presentationEditing = !presentationEditing)}
>{presentationEditing ? 'Einstellungen schließen' : 'Präsentation bearbeiten'}</button
>
<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-controls" hidden={!presentationEditing}>
<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} />
<EditorialMarkdownField
name="presentationText"
label="Präsentationstext"
emptyText="Text für die Publikumsausgabe schreiben"
bind:value={draft.presentationText}
html={rendered.presentation}
rows={8}
/>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label
>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
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions">
<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}
</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>
<section class="context-card asset-summary">
<div>
<p class="eyebrow">Material</p>
<h2>Bilder</h2>
<p>
{data.piece.media.length
? `${data.piece.media.length} gespeichert`
: 'Noch keine Bilder'}
</p>
</div>
<a class="text-link" href="#piece-media">Bilder verwalten ↓</a>
</section>
</aside>
</div>
<div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
<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"
@@ -456,7 +683,7 @@
<input type="hidden" name="revision" value={revision} />
</form>
<section class="editor-section media-editor">
<section id="piece-media" class="editor-section media-editor supporting-editor-section">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>