Release 0.5.3

This commit is contained in:
hnzio committed 2026-10-22 14:18:57 +02:00
1 parent 25da870601
commit f466df2c5c
13 files changed
+1186 -590

No files matched your search

+22 -10
View File
@@ -71,16 +71,28 @@ als bereits implementiert verstanden werden.
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- der eigenständige Piece-Editor ist als redaktionelles Arbeitsblatt aufgebaut:
Titel, Fundstück, private Recherche und die getrennt gespeicherten
Sendungstexte stehen im Schreibfluss; Quellen, Einordnung, Präsentation und
Bilder begleiten ihn als Desktop-Kontextleiste oder mobile Werkzeuge
- Markdown-Felder wechseln einzeln zwischen Lesen und Bearbeiten, sodass mehrere
Textbereiche gleichzeitig offen bleiben; Autosave, Konfliktschutz und
sichtbare Speicherfehler verwenden unverändert die Piece-Revision
- die Schnellaufnahme „Aufnehmen und öffnen“ leitet nach erfolgreicher Anlage
direkt zum neuen Piece weiter, statt den SvelteKit-Redirect als
Validierungsfehler abzufangen
- Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
Original sowie Verwendung bleiben getrennte Revisionsgrenzen
- Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
- Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
Vorschau stehen gemeinsam im Präsentationsbereich
- der Dialog bleibt innerhalb der Desktop-Fensterhöhe, hält Kopf,
Bereichsnavigation und Aktionen sichtbar und nutzt mobil die volle
Bildschirmfläche; Bereichswechsel hängen Eingaben nicht aus und merken die
jeweilige Scrollposition
- die Schnellaufnahme akzeptiert Gedanke oder URL, bleibt nach dem Ablegen im
Eingang und öffnet die Ausarbeitung erst über eine bewusste Aktion
Für diese gezielte Überarbeitung wurden `npm run check` sowie kurze
Chromium-Sichtkontrollen des Original- und Verwendungseditors bei 1440 × 1000
und 390 × 844 Pixeln ausgeführt. Dabei waren Dialoggrenzen, feste Bedienbereiche,
alle drei Arbeitsbereiche und horizontaler Überlauf unauffällig. Die vollständige
Unit-, E2E- und Build-Matrix ist gemäß Auftrag noch offen.
## Umgesetzter Stand Etappe 4
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
"version": "0.5.2",
"version": "0.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.5.2",
"version": "0.5.3",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.5.2",
"version": "0.5.3",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+511
View File
@@ -3231,6 +3231,430 @@ a {
color: var(--accent);
}
.piece-editor-route {
min-height: calc(100dvh - 2rem);
}
.piece-editor-dialog {
display: none;
width: min(76rem, calc(100vw - 2rem));
height: min(54rem, calc(100dvh - 2rem));
max-width: none;
max-height: none;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-large);
background: var(--panel-strong);
box-shadow: var(--shadow-large);
color: var(--text);
}
.piece-editor-dialog[open] {
display: grid;
}
.piece-editor-dialog::backdrop {
background: color-mix(in srgb, #07110e 62%, transparent);
backdrop-filter: blur(6px);
}
.piece-dialog-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1rem 0.75rem;
border-bottom: 1px solid var(--line);
background: var(--panel-strong);
}
.piece-dialog-heading {
min-width: 0;
flex: 1;
}
.piece-dialog-heading > .eyebrow {
margin-bottom: 0.18rem;
}
.piece-dialog-heading > small {
display: block;
margin-top: 0.25rem;
color: var(--text-faint);
font-size: 0.65rem;
}
.piece-dialog-title {
display: block;
}
.piece-dialog-title textarea {
width: 100%;
min-width: 0;
height: 1.2em;
padding: 0;
resize: none;
overflow: hidden;
border: 0;
outline-offset: 0.2rem;
background: transparent;
color: var(--text);
font: inherit;
font-size: clamp(1.25rem, 2.5vw, 1.85rem);
font-weight: 760;
letter-spacing: -0.035em;
}
.piece-dialog-tabs {
display: flex;
min-width: 0;
gap: 0.25rem;
padding: 0.45rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel-strong);
}
.piece-dialog-tabs button {
padding: 0.48rem 0.72rem;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 740;
cursor: pointer;
}
.piece-dialog-tabs button.active {
background: var(--accent-soft);
color: var(--accent);
}
.piece-dialog-workspace {
min-width: 0;
min-height: 0;
padding: clamp(0.85rem, 2vw, 1.4rem);
overflow: auto;
overscroll-behavior: contain;
}
.piece-dialog-form {
display: block;
}
.piece-editor-panel {
width: min(100%, 66rem);
margin: 0 auto;
}
.piece-editor-panel[hidden] {
display: none;
}
.piece-panel-heading,
.piece-subsection-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.piece-panel-heading h2,
.piece-panel-heading h3,
.piece-subsection-heading h3 {
margin: 0.12rem 0 0;
font-size: 1.05rem;
}
.piece-panel-heading small,
.piece-subsection-heading small {
color: var(--text-faint);
font-size: 0.65rem;
}
.piece-writing-stack {
display: grid;
gap: 0.8rem;
}
.markdown-editor-field {
display: block;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.markdown-editor-field.private-field {
border-color: color-mix(in srgb, var(--gold) 38%, var(--line));
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
}
.markdown-editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.45rem;
}
.markdown-editor-heading > span:first-child {
font-size: 0.76rem;
font-weight: 760;
}
.markdown-editor-heading small {
margin-left: 0.4rem;
color: var(--gold-strong);
font-size: 0.58rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.markdown-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
}
.markdown-toolbar button {
min-width: 1.8rem;
padding: 0.22rem 0.4rem;
border: 1px solid var(--line);
border-radius: 0.45rem;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.65rem;
cursor: pointer;
}
.markdown-editor-field textarea {
display: block;
width: 100%;
min-height: 7.5rem;
padding: 0.65rem;
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.55;
}
.piece-meta-details,
.piece-source-editor,
.piece-media-panel {
margin-top: 0.9rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.piece-meta-details summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
font-size: 0.75rem;
font-weight: 740;
}
.piece-meta-details summary small {
color: var(--text-faint);
font-size: 0.64rem;
font-weight: 600;
}
.piece-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 0.8rem;
}
.piece-meta-grid > label,
.piece-meta-grid fieldset,
.piece-source-editor label,
.piece-presentation-controls .presentation-settings > label {
display: grid;
gap: 0.25rem;
margin: 0;
color: var(--text-muted);
font-size: 0.66rem;
font-weight: 700;
}
.piece-meta-grid input:not([type='checkbox']),
.piece-meta-grid select,
.piece-meta-grid textarea,
.piece-source-editor input,
.piece-presentation-controls input:not([type='radio']),
.piece-presentation-controls select,
.piece-presentation-controls textarea,
.piece-dialog-secondary-actions select {
width: 100%;
min-width: 0;
padding: 0.55rem 0.62rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
}
.piece-meta-grid .wide-field,
.piece-meta-grid fieldset {
grid-column: 1 / -1;
}
.piece-meta-grid fieldset {
padding: 0;
border: 0;
}
.piece-source-editor .source-row {
display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(14rem, 1.5fr) minmax(8rem, 0.6fr) auto;
align-items: end;
gap: 0.5rem;
padding: 0.6rem 0;
border-top: 1px solid var(--line);
}
.piece-source-summary {
margin-top: 0.9rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.piece-source-summary strong,
.piece-source-summary small {
display: block;
margin-top: 0.3rem;
overflow-wrap: anywhere;
}
.piece-source-summary small,
.piece-source-summary p:not(.eyebrow) {
color: var(--text-muted);
font-size: 0.7rem;
}
.piece-presentation-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.8fr);
align-items: start;
gap: 1rem;
}
.piece-presentation-controls,
.piece-presentation-controls .presentation-settings {
display: grid;
gap: 0.7rem;
}
.piece-presentation-preview {
min-width: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--presentation-bg, #0b1220);
color: #fff7e8;
}
.piece-presentation-preview > .output-content {
min-height: 20rem;
padding: 1rem;
}
.piece-presentation-preview .output-content h1 {
max-width: none;
margin: 0.35rem 0 0.75rem;
font-size: clamp(1.55rem, 3vw, 2.4rem);
line-height: 1;
}
.piece-presentation-preview .output-copy,
.piece-presentation-preview .output-link-copy > p:not(.output-kicker) {
font-size: 0.9rem;
}
.piece-presentation-preview .output-link-card {
grid-template-columns: 1fr;
gap: 0.8rem;
}
.piece-presentation-preview .output-link-image {
min-height: 10rem;
}
.piece-presentation-preview .output-sources {
margin-top: 1rem;
}
.piece-presentation-preview > p,
.piece-presentation-preview > h4,
.piece-presentation-preview > img {
margin: 0.8rem;
}
.piece-presentation-preview > img {
max-width: calc(100% - 1.6rem);
max-height: 12rem;
object-fit: contain;
}
.preview-markdown-source {
white-space: pre-wrap;
}
.piece-dialog-footer {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.65rem 1rem;
border-top: 1px solid var(--line);
background: var(--panel-strong);
}
.piece-dialog-secondary-actions,
.piece-dialog-primary-action {
display: flex;
align-items: center;
gap: 0.55rem;
}
.piece-dialog-secondary-actions form {
display: flex;
align-items: center;
gap: 0.3rem;
}
.piece-dialog-primary-action span {
color: var(--text-muted);
font-size: 0.66rem;
}
.route-editor-error {
position: fixed;
z-index: 100;
top: 1rem;
left: 50%;
transform: translateX(-50%);
}
.usage-editor-dialog {
width: min(60rem, calc(100vw - 2rem));
max-width: none;
@@ -4041,6 +4465,93 @@ a {
.usage-dialog-actions button {
width: 100%;
}
.piece-editor-dialog {
width: 100vw;
height: 100dvh;
border: 0;
border-radius: 0;
}
.piece-dialog-header {
padding: 0.75rem 0.8rem 0.6rem;
}
.piece-dialog-heading > small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.piece-dialog-title textarea {
height: 2.35em;
font-size: 1.3rem;
line-height: 1.12;
}
.piece-dialog-tabs {
overflow-x: auto;
padding: 0.4rem 0.6rem;
}
.piece-dialog-tabs button {
flex: 1 0 auto;
padding-inline: 0.55rem;
}
.piece-dialog-workspace {
padding: 0.7rem;
}
.piece-panel-heading,
.piece-subsection-heading,
.markdown-editor-heading {
align-items: flex-start;
flex-direction: column;
gap: 0.4rem;
}
.markdown-toolbar {
width: 100%;
}
.markdown-toolbar button {
min-height: 2rem;
}
.piece-meta-details summary {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
.piece-meta-grid,
.piece-source-editor .source-row,
.piece-presentation-layout {
grid-template-columns: 1fr;
}
.piece-source-editor .source-row {
align-items: stretch;
}
.piece-source-editor .source-row > button {
justify-self: start;
}
.piece-presentation-preview > .output-content {
min-height: 16rem;
}
.piece-dialog-footer {
align-items: center;
padding: 0.5rem 0.65rem calc(0.5rem + env(safe-area-inset-bottom));
}
.piece-dialog-secondary-actions {
display: grid;
min-width: 0;
max-width: calc(100% - 11rem);
grid-auto-columns: max-content;
grid-auto-flow: column;
align-items: center;
overflow-x: auto;
}
.piece-dialog-secondary-actions form {
flex: 0 0 auto;
}
.piece-dialog-secondary-actions .text-button {
white-space: nowrap;
}
.piece-dialog-primary-action {
margin-left: auto;
}
.piece-dialog-primary-action span {
display: none;
}
.library-toolbar {
align-items: stretch;
flex-direction: column;
@@ -1,60 +0,0 @@
<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>
@@ -0,0 +1,81 @@
<script lang="ts">
import { tick } from 'svelte';
let {
name,
label,
value = $bindable(),
rows = 7,
placeholder = '',
privateField = false,
form
}: {
name: string;
label: string;
value: string;
rows?: number;
placeholder?: string;
privateField?: boolean;
form?: string;
} = $props();
let textarea: HTMLTextAreaElement;
async function replaceSelection(before: string, after = before, fallback = 'Text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || fallback;
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
async function addList() {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = value.slice(start, end) || 'Stichpunkt';
const replacement = selected
.split('\n')
.map((line) => `- ${line}`)
.join('\n');
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
await tick();
textarea.focus();
textarea.setSelectionRange(start + 2, start + replacement.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
</script>
<div class:private-field={privateField} class="markdown-editor-field">
<div class="markdown-editor-heading">
<span
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
>
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
><strong>B</strong></button
>
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button
>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button>
<button
type="button"
aria-label="Link"
title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button
>
</span>
</div>
<textarea
bind:this={textarea}
aria-label={label}
{name}
{rows}
{placeholder}
{form}
maxlength="50000"
bind:value></textarea>
</div>
@@ -0,0 +1,81 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
let {
labelledby,
activeSection = $bindable(),
onrequestclose,
header,
children,
footer
}: {
labelledby: string;
activeSection: 'script' | 'research' | 'presentation';
onrequestclose: () => void;
header: Snippet;
children: Snippet;
footer: Snippet;
} = $props();
let dialog: HTMLDialogElement;
let workspace: HTMLDivElement;
const scrollPositions = { script: 0, research: 0, presentation: 0 };
async function selectSection(section: typeof activeSection) {
scrollPositions[activeSection] = workspace.scrollTop;
activeSection = section;
await tick();
workspace.scrollTop = scrollPositions[section];
}
export function showModal() {
if (!dialog.open) dialog.showModal();
}
export function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="piece-editor-dialog"
aria-labelledby={labelledby}
oncancel={(event) => {
event.preventDefault();
onrequestclose();
}}
onclick={(event) => event.target === dialog && onrequestclose()}
>
<header class="piece-dialog-header">
<div class="piece-dialog-heading">{@render header()}</div>
<button
class="dialog-close"
type="button"
aria-label="Editor schließen"
onclick={onrequestclose}>×</button
>
</header>
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
<button
type="button"
class:active={activeSection === 'script'}
aria-current={activeSection === 'script' ? 'page' : undefined}
onclick={() => selectSection('script')}>Sendungstext</button
>
<button
type="button"
class:active={activeSection === 'research'}
aria-current={activeSection === 'research' ? 'page' : undefined}
onclick={() => selectSection('research')}>Recherche & Quellen</button
>
<button
type="button"
class:active={activeSection === 'presentation'}
aria-current={activeSection === 'presentation' ? 'page' : undefined}
onclick={() => selectSection('presentation')}>Präsentation</button
>
</nav>
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
<footer class="piece-dialog-footer">{@render footer()}</footer>
</dialog>
+219 -112
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
@@ -19,12 +21,30 @@
error?: string;
enhanceCommand: SubmitFunction;
} = $props();
let dialog: HTMLDialogElement;
let presentationMode = $state(untrack(() => usage.presentationMode));
let formId = $derived(`usage-editor-form-${usage.id}`);
let editor: { showModal: () => void; close: () => void };
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let draft = $state(untrack(() => ({ ...usage })));
let dirty = $state(false);
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false;
editor.close();
}
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
if (finish) await finish(result);
if (result.result.type === 'success') dirty = false;
};
};
</script>
<button
@@ -32,45 +52,79 @@
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()}
onclick={() => editor.showModal()}
>
<NavIcon name="edit" />
</button>
<dialog
bind:this={dialog}
class="usage-editor-dialog"
aria-labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()}
<PieceEditorDialog
bind:this={editor}
bind:activeSection
labelledby={`usage-editor-title-${usage.id}`}
onrequestclose={requestClose}
>
<header>
<div>
<p class="eyebrow">Sendungsspezifische Kopie</p>
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
</div>
<form method="dialog">
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
</form>
</header>
<div class="usage-dialog-scroll">
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
{#snippet header()}
<p class="eyebrow">Verwendung in dieser Sendung</p>
<label class="piece-dialog-title">
<span class="sr-only">Titel</span>
<textarea
form={formId}
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}
oninput={() => (dirty = true)}></textarea>
</label>
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
<small>Änderungen gelten nur für diese Einplanung.</small>
{/snippet}
<form
use:enhance={enhanceCommand}
id={formId}
use:enhance={enhanceEditor}
method="POST"
action="?/updateUsage"
class="usage-dialog-editor"
class="piece-dialog-form"
oninput={() => (dirty = true)}
onchange={() => (dirty = true)}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="editor-section">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h3>Titel, Dauer und Notizen</h3>
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h3>Sendungstext</h3>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" value={usage.title} /></label
>
<small>Die drei Teile bleiben getrennt gespeichert.</small>
</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="moderation"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
name="transition"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transition}
/>
</div>
<details class="piece-meta-details">
<summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid">
<label
>Dauer in Minuten<input
name="durationMinutes"
@@ -81,83 +135,102 @@
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div>
</details>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h3>Getrennte Arbeitsbereiche</h3>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h3>Recherche & Quellen</h3>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
>{usage.intro}</textarea
></label
>
<label class="private-field"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
>{usage.research}</textarea
></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderation"
rows="7"
maxlength="50000">{usage.moderation}</textarea
></label
>
<label
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
>{usage.transition}</textarea
></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
>{usage.presentation}</textarea
></label
>
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
</div>
<div class="presentation-settings">
<div class="piece-writing-stack">
<MarkdownEditorField
name="research"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.research}
/>
<MarkdownEditorField
name="ownNotes"
label="Interne Notizen zu dieser Verwendung"
placeholder="Absprachen und Hinweise nur für diese Sendung"
privateField
bind:value={draft.ownNotes}
/>
</div>
<section class="piece-source-summary">
<p class="eyebrow">Snapshot-Quelle</p>
{#if draft.presentationSourceUrl}
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
<small>{draft.presentationSourceUrl}</small>
{:else}
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
{/if}
</section>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Öffentliche Ausgabe</p>
<h3>Präsentation</h3>
</div>
<small>Private Recherche und Notizen werden nicht ausgegeben.</small>
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
<option value="text">Text</option>
<option value="link">Linkvorschau</option>
<option value="video">Video</option>
</select></label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{#if presentationMode !== 'text'}
{/each}
</div>
<input
type="hidden"
name="presentationSourceLabel"
value={usage.presentationSourceLabel}
value={draft.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={usage.presentationSourceTimecodeSeconds ?? ''}
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
{#if draft.presentationMode !== 'text'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{usage.presentationSourceLabel ||
usage.presentationSourceUrl ||
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
value={usage.presentationTitle}
bind:value={draft.presentationTitle}
/></label
>
<label
@@ -165,52 +238,79 @@
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}></textarea></label
>
</div>
{:else}
<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">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.presentationTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{:else}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{/if}
</aside>
</div>
</section>
<button class="primary-button" type="submit">Verwendung speichern</button>
</form>
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
<div>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>
</form>
<small
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit">Duplizieren</button>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button" type="submit" disabled={dirty}>Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="position" value="9999" />
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" />
<label
>Ziel<select name="sectionId">
<option value="">Reservebank</option>
{#each sections as target (target.id)}
<option value={target.id} selected={target.id === sectionId}>{target.name}</option>
{/each}
</select></label
><span class="sr-only">Ziel</span><select name="sectionId"
><option value="">Reservebank</option>{#each sections as target (target.id)}<option
value={target.id}
selected={target.id === sectionId}>{target.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Verschieben</button>
<button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
</form>
<form
use:enhance={enhanceCommand}
@@ -219,10 +319,17 @@
onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button danger-text" type="submit">Entfernen</button>
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="usageId"
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}>Entfernen</button>
</form>
</section>
</div>
</dialog>
<div class="piece-dialog-primary-action">
{#if error}<span class="problem" role="alert">{error}</span>{/if}
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
</div>
{/snippet}
</PieceEditorDialog>
+3 -2
View File
@@ -620,10 +620,11 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
}
export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
let title = cleanText(titleValue, 200, 'title');
const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
if (!title && !url) throw new Error('INVALID_TITLE');
if (!title) title = new URL(url).hostname;
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
+3 -4
View File
@@ -1,4 +1,4 @@
import { fail, redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } from '#lib/server/content.js';
@@ -12,9 +12,9 @@ export const actions = {
capture: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
return { captured: true, capturedId: id };
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
@@ -26,6 +26,5 @@ export const actions = {
: 'Ein Titel ist erforderlich.'
});
}
redirect(303, `/fundus/${id}`);
}
} satisfies Actions;
+6 -3
View File
@@ -17,11 +17,14 @@
<h2>Was soll nicht verloren gehen?</h2>
</div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.captured}<div class="form-message success" role="status">
Im Eingang gespeichert.
<a href={resolve('/fundus/[id]', { id: form.capturedId })}>Jetzt ausarbeiten</a>
</div>{/if}
<form method="POST" action="?/capture" class="capture-form">
<label
>Titel oder Gedanke<input
>Gedanke <span>optional bei URL</span><input
name="title"
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Kurze Idee festhalten"
@@ -36,7 +39,7 @@
placeholder="https://…"
/></label
>
<button class="primary-button" type="submit">Aufnehmen und öffnen</button>
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
{#if data.pieces.length}
+219 -360
View File
@@ -2,25 +2,24 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, tick, untrack } from 'svelte';
import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.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';
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))));
let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
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 classificationEditing = $state(false);
let presentationEditing = $state(false);
let mobileContext = $state<ContextPanel | null>(null);
let sourceImageUnavailable = $state(false);
let editorDialog: { showModal: () => void; close: () => void };
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
@@ -41,13 +40,34 @@
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 previewContent = $derived<PresentationContent>({
mode: draft.presentationMode,
title: 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}`
: ''
});
let pendingHref: string | null = null;
let allowNavigation = false;
let contextTrigger: HTMLElement | null = null;
const statusLabels = {
inbox: 'Eingang',
@@ -55,6 +75,7 @@
ready: 'Sendefertig'
};
onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
if (allowNavigation) return;
@@ -92,6 +113,23 @@
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();
@@ -119,23 +157,6 @@
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;
@@ -176,259 +197,84 @@
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:window onkeydown={handleEscape} />
<div class="page-wrap editor-page worksheet-page">
{#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 worksheet-editor"
oninput={scheduleSave}
onchange={scheduleSave}
<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}
>
<input type="hidden" name="revision" value={revision} />
<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">
{#snippet header()}
<p class="eyebrow">Fundus-Original</p>
<label class="piece-dialog-title">
<span class="sr-only">Titel</span>
<textarea
form="piece-editor-form"
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}></textarea>
bind:value={draft.title}
oninput={scheduleSave}></textarea>
</label>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
<small>Original im Fundus · Änderungen wirken nicht rückwirkend auf Einplanungen.</small>
{/snippet}
<nav class="mobile-editor-tools" aria-label="Piece-Werkzeuge">
<button
type="button"
aria-expanded={mobileContext === 'sources'}
onclick={(event) => openContext(event, 'sources')}>Quellen</button
<form
bind:this={editorForm}
id="piece-editor-form"
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-dialog-form"
oninput={scheduleSave}
onchange={scheduleSave}
>
<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>
<input type="hidden" name="revision" value={revision} />
<div class="editor-workspace">
<main class="editorial-worksheet" aria-label="Redaktionelles Arbeitsblatt">
<section
class:has-image={sourcePreviewImage && !sourceImageUnavailable}
class="source-feature"
>
{#if sourcePreviewImage && !sourceImageUnavailable}
<img src={sourcePreviewImage} alt="" onerror={() => (sourceImageUnavailable = true)} />
{/if}
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<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
>
{: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>
<p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2>
<p>Einleitung, Moderation und Übergang bleiben getrennt gespeichert.</p>
</div>
<div class="script-sections">
<EditorialMarkdownField
<small>Die drei Teile bleiben getrennt gespeichert.</small>
</div>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
emptyText="Wie beginnt dieses Thema in der Sendung?"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
html={rendered.intro}
/>
<EditorialMarkdownField
<MarkdownEditorField
name="moderationNotes"
label="Moderationsstichpunkte"
emptyText="Die wichtigsten Punkte für die Moderation notieren"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderationNotes}
html={rendered.moderation}
/>
<EditorialMarkdownField
<MarkdownEditorField
name="transitionText"
label="Überleitung"
emptyText="Den Übergang zum nächsten Thema vorbereiten"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText}
html={rendered.transition}
/>
</div>
</section>
</main>
<aside
class:context-open={mobileContext}
class="editor-context"
aria-label="Werkzeuge zum Piece"
<details class="piece-meta-details">
<summary>
<span>Status, Dauer und Einordnung</span>
<small
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
>
<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}>
</summary>
<div class="piece-meta-grid">
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing"
@@ -454,7 +300,7 @@
>{/each}</select
></label
>
<label
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="3"
@@ -488,57 +334,90 @@
</div>
</fieldset>
</div>
</details>
</section>
<section
class:mobile-active={mobileContext === 'presentation'}
class="context-card presentation-editor"
aria-labelledby="presentation-context-title"
>
<header class="context-card-heading">
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Publikum</p>
<h2 id="presentation-context-title">Präsentation</h2>
<p class="eyebrow">Nicht öffentlich</p>
<h2>Recherche & Quellen</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}
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</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
<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"
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}
</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">Präsentation testen</a
rel="noopener">In neuem Fenster testen</a
>
</div>
<div class="presentation-controls" hidden={!presentationEditing}>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
@@ -549,29 +428,6 @@
/>{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
@@ -601,7 +457,7 @@
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
@@ -609,7 +465,7 @@
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions">
<div class="presentation-preview-actions wide-field">
<button
class="secondary-button"
type="button"
@@ -623,7 +479,13 @@
>
</div>
</div>
{/if}
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
content={previewContent}
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
/>
</aside>
</div>
<input
type="hidden"
@@ -649,45 +511,15 @@
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
</form>
<section class="context-card asset-summary">
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
<div class="piece-subsection-heading">
<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}
>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 id="piece-media" class="editor-section media-editor supporting-editor-section">
<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>
<h3>Material</h3>
</div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
@@ -726,18 +558,45 @@
</form>
</section>
<section class="editor-actions">
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
>
<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="secondary-button danger-text"
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
aria-label="Piece archivieren"
disabled={actionsBlocked}>Archivieren</button
>
</form>
</section>
</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"
type="submit"
form="piece-editor-form"
disabled={saving}>Jetzt 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>
+38 -36
View File
@@ -31,11 +31,13 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
}
async function openEditorialField(page: Page, heading: string) {
const field = page.locator('.editorial-field').filter({
has: page.getByRole('heading', { name: heading, exact: true })
});
await field.locator('.editorial-edit-button').click();
return field;
if (heading.includes('Recherche')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
}
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
@@ -1022,23 +1024,26 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await page.getByText('Status, Dauer und Einordnung').click();
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await openEditorialField(page, 'Private Recherche');
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte');
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
await openEditorialField(page, 'Präsentationstext');
await page
.getByLabel('Präsentationstext')
.getByLabel('Öffentlicher Text')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag');
@@ -1047,18 +1052,14 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await page
.locator('.editorial-field')
.filter({ has: page.getByRole('heading', { name: 'Präsentationstext', exact: true }) })
.getByRole('button', { name: 'Lesen' })
.click();
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await openEditorialField(page, 'Präsentationstext');
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
@@ -1089,7 +1090,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -1098,7 +1099,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
});
await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
@@ -1135,9 +1136,8 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await page.getByRole('button', { name: 'Quellen', exact: true }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Quellen schließen' }).click();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
@@ -1155,17 +1155,18 @@ test('a quickly captured video can be written and previewed before classificatio
})
);
await page.goto('/eingang');
await page.getByLabel('Titel oder Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Aufnehmen und öffnen' }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
await expect(page.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute(
'href',
await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await openEditorialField(page, 'Private Recherche');
await page
.getByLabel('Private Recherche')
.fill('Warum funktioniert der Einstieg so unmittelbar?');
@@ -1173,15 +1174,14 @@ test('a quickly captured video can be written and previewed before classificatio
await page
.getByLabel('Moderationsstichpunkte')
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Video').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Präsentation testen' }).click();
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src',
@@ -1192,7 +1192,7 @@ test('a quickly captured video can be written and previewed before classificatio
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Video')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation schließen' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await expectNoHorizontalOverflow(page);
});
@@ -1201,21 +1201,21 @@ test('link preview uses one existing source, persists manual fields and isolates
page
}, testInfo) => {
await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Linkvorschau').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau'
);
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Präsentation testen' }).click();
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await preview.waitForLoadState('networkidle');
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
@@ -1236,8 +1236,10 @@ test('link preview uses one existing source, persists manual fields and isolates
});
await preview.close();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview')
);