2 Commits
Author SHA1 Message Date
hnzio f466df2c5c Release 0.5.3 2026-10-22 14:18:57 +02:00
hnzio 25da870601 Release 0.5.2 2026-10-22 13:51:30 +02:00
14 changed files with 2136 additions and 530 deletions

No files matched your search

+26
View File
@@ -189,3 +189,29 @@
Pixeln visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Redaktionelles Piece-Arbeitsblatt
| Kriterium | Nachweis | Stand |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Piece-Arbeitsblatt
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
1 von 1 Browsertest erfolgreich
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich
+12
View File
@@ -239,6 +239,18 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
+22
View File
@@ -71,6 +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
- 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.1",
"version": "0.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.5.1",
"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.1",
"version": "0.5.3",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+1096 -1
View File
File diff suppressed because it is too large. Load diff
@@ -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>
+263 -156
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}
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-dialog-editor"
>
<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>
{#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
id={formId}
use:enhance={enhanceEditor}
method="POST"
action="?/updateUsage"
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="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,136 +135,182 @@
value={durationInMinutes(usage.durationSeconds)}
/></label
>
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div>
</section>
</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="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>
<div class="presentation-settings">
<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
>
{#if presentationMode !== 'text'}
<input
type="hidden"
name="presentationSourceLabel"
value={usage.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={usage.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{usage.presentationSourceLabel ||
usage.presentationSourceUrl ||
'Keine Quelle'}</strong
<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
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
<input
type="hidden"
name="presentationSourceLabel"
value={draft.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<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
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
bind:value={draft.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}></textarea></label
>
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
value={usage.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>
{: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>
</section>
<button class="primary-button" type="submit">Verwendung speichern</button>
</form>
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
<div>
<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}
>Aus Fundus aktualisieren</button
>
</form>
<small
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
>
<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>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
</section>
</form>
{#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">Duplizieren</button>
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
>Aus Fundus aktualisieren</button
>
</form>
<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="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>
<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, {
+2 -2
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';
@@ -14,7 +14,7 @@ export const actions = {
const data = await request.formData();
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
return { captured: true, capturedId: id };
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
+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}
+445 -359
View File
@@ -2,8 +2,11 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import { 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';
let { data, form }: PageProps = $props();
@@ -12,8 +15,11 @@
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 sourceImageUnavailable = $state(false);
let editorDialog: { showModal: () => void; close: () => void };
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
@@ -26,9 +32,50 @@
saveState === 'error' ||
saveState === 'conflict'
);
let featuredSource = $derived(
draft.presentationSourcePosition === null
? (sources[0] ?? null)
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
);
let sourcePreviewImage = $derived(
draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: ''
);
let 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;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
if (allowNavigation) return;
@@ -66,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();
@@ -87,6 +151,7 @@
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
@@ -133,319 +198,398 @@
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<div class="page-wrap editor-page">
<header class="editor-header">
<div>
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
bind:this={editorForm}
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
oninput={scheduleSave}
onchange={scheduleSave}
<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} />
<section class="editor-section editor-basics">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h2>Worum geht es?</h2>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
>
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
{#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}
oninput={scheduleSave}></textarea>
</label>
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
<small>Original im Fundus · Änderungen wirken nicht rückwirkend auf Einplanungen.</small>
{/snippet}
<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>
<form
bind:this={editorForm}
id="piece-editor-form"
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-dialog-form"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h2>Getrennte Arbeitsbereiche</h2>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
</div>
</section>
<section class="editor-section presentation-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Präsentation</p>
<h2>Darstellung in heinzlive</h2>
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
</div>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">Präsentation testen</a
>
</div>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input type="hidden" name="presentationDescription" value={draft.presentationDescription} />
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
<label class="presentation-text-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label class="wide-field"
>Quelle<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
>
<option value={null}>Quelle auswählen</option>
{#each sources as source, index (index)}
{#if source.url}<option value={index}>{source.label || source.url}</option>{/if}
{/each}
</select></label
>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
>
<small>
{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}
</small>
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Für die Moderation</p>
<h2>Sendungstext</h2>
</div>
<small>Die drei Teile bleiben getrennt gespeichert.</small>
</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>
<div class="piece-writing-stack">
<MarkdownEditorField
name="intro"
label="Einleitung"
placeholder="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
/>
<MarkdownEditorField
name="moderationNotes"
label="Moderationsstichpunkte"
placeholder="Argumente, Fragen und Ablaufpunkte"
rows={10}
bind:value={draft.moderationNotes}
/>
<MarkdownEditorField
name="transitionText"
label="Überleitung"
placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText}
/>
</div>
<details class="piece-meta-details">
<summary>
<span>Status, Dauer und Einordnung</span>
<small
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
>
</summary>
<div class="piece-meta-grid">
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing"
>In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
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>
</details>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h2>Recherche & Quellen</h2>
</div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<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">
<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">In neuem Fenster testen</a
>
</div>
<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
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>{/each}
</div>
<div class="presentation-settings">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
>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={source.url}
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
<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>
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
content={previewContent}
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
/>
</aside>
</div>
<input
type="hidden"
name="presentationTitleManual"
value={draft.presentationTitleManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationDescriptionManual"
value={draft.presentationDescriptionManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationImageManual"
value={draft.presentationImageManual ? '1' : '0'}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
</form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Private Dateien</p>
<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>
<img src={`/media/${media.id}`} alt={media.altText} />
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
</form>
</section>
<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>
{#snippet footer()}
<div class="piece-dialog-secondary-actions">
<form method="POST" action="?/duplicate">
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
aria-label="Piece archivieren"
disabled={actionsBlocked}>Archivieren</button
>
</form>
</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"
@@ -455,62 +599,4 @@
>
<input type="hidden" name="revision" value={revision} />
</form>
<section class="editor-section media-editor">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} />
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
</form>
</section>
<section class="editor-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="secondary-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
>
</form>
</section>
</div>
+96 -4
View File
@@ -30,6 +30,16 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function openEditorialField(page: Page, heading: string) {
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) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
@@ -1014,16 +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.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, '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 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');
@@ -1032,11 +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 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.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
@@ -1067,6 +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.getByText('Status, Dauer und Einordnung').click();
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -1075,6 +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.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
@@ -1099,9 +1124,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'Kopie von E2E Fundstück, extern aktualisiert'
);
await page.context().setOffline(true);
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000
});
await page.context().setOffline(false);
await page.getByRole('button', { name: 'Jetzt speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
@@ -1109,23 +1145,77 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
});
});
test('a quickly captured video can be written and previewed before classification', async ({
page
}) => {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>YouTube Test</title>'
})
);
await page.goto('/eingang');
await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
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, 'Private Recherche');
await page
.getByLabel('Private Recherche')
.fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByLabel('Moderationsstichpunkte')
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
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: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
);
await preview.close();
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: 'Recherche & Quellen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
page
}, testInfo) => {
await page.goto('/fundus/piece-console');
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', 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();
@@ -1146,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')
);