5 Commits
Author SHA1 Message Date
hnzio 22ae3a68d6 Release 0.5.7 2026-10-23 00:14:43 +02:00
hnzio dfa236390d Release 0.5.6 2026-10-22 20:54:46 +02:00
hnzio b2467b87f5 Release 0.5.5 2026-10-22 20:41:34 +02:00
hnzio 4fed048843 Release 0.5.4 2026-10-22 19:17:14 +02:00
hnzio f466df2c5c Release 0.5.3 2026-10-22 14:18:57 +02:00
16 changed files with 2236 additions and 1027 deletions

No files matched your search

+91
View File
@@ -215,3 +215,94 @@
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um, Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich - `bash -n start.sh` und `git diff --check`: erfolgreich
## Oberflächenrelease 0.5.4
| Kriterium | Nachweis | Stand |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------- | --------------------- |
| Klare Hierarchie in beiden Themes | Eingang, Fundus, Editor, Sendungsplan und Live-Steuerung in Light/Dark visuell kontrolliert | erfüllt am 22.10.2026 |
| Exakte heinzlive-Darkmode-Farben | berechnete Theme-Tokens und Screenshots der dunklen Arbeitsansichten geprüft | erfüllt am 22.10.2026 |
| Responsive Darstellung ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln auf allen gezielt geprüften Ansichten | erfüllt am 22.10.2026 |
| Sendungsplan-Aktionen bleiben erreichbar | drei sichtbare Aktionen einer Planzeile liegen waagerecht auf gleicher Höhe; Mobilbild geprüft | erfüllt am 22.10.2026 |
| Fokus und reduzierte Bewegung | sichtbarer Cyan-Fokusring und bestehende `prefers-reduced-motion`-Regeln im Stylesheet geprüft | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenrelease 0.5.4
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Chromium-Lauf für Eingang, Fundus, Piece-Editor, Sendungsplan und
Live-Steuerung bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne
Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop- und Mobilaufnahmen aller genannten Ansichten visuell kontrolliert
- vollständige Unit- und E2E-Matrix nicht erneut ausgeführt, weil dieser Release
ausschließlich Darstellung und redaktionelle Kurztexte verändert
- `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.5
| Kriterium | Nachweis | Stand |
| ------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------- |
| Nachtblau und Glaswirkung erkennbar | berechnete Transparenz/Unschärfe und Desktop-/Mobilaufnahmen in Darkmode geprüft | erfüllt am 22.10.2026 |
| Schreibfelder im Ruhezustand sichtbar | deckende Feldfarbe, feine Kontur, Innenabstand und Textcursor im Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Goldener Tastaturfokus | berechnete Fokusfarbe und Fokus-Schatten am Titelfeld geprüft; Cyanrahmen nicht mehr aktiv | erfüllt am 22.10.2026 |
| Inhaltsgerechte Feldhöhen | kurze Einleitung ist niedriger als ausführliche Moderationsfläche; beide bleiben skalierbar | erfüllt am 22.10.2026 |
| Lightmode und Smartphone erhalten | fünf Arbeitsansichten bei 1440 × 1000 und 390 × 844 Pixeln ohne Seitenüberlauf geprüft | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.5
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Chromium-Lauf in Light/Dark und Desktop/Mobil ohne Konsolenfehler
oder horizontalen Seitenüberlauf; Glas-, Feld- und Fokuswerte zusätzlich
programmatisch geprüft
- vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt
- `git diff --check`: erfolgreich
## Oberflächenkorrektur 0.5.6
| Kriterium | Nachweis | Stand |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Gold-/Violettlicht und Glasflächen sichtbar | Fundus und Editor in Light/Dark auf Desktop und Mobil visuell sowie über berechnete Werte geprüft | erfüllt am 22.10.2026 |
| Schreibflächen ruhig und eindeutig abgegrenzt | deckende Feldfarbe, Kontur und goldener Fokus im geöffneten Piece-Editor geprüft | erfüllt am 22.10.2026 |
| Neuanlage und Suche unmittelbar erreichbar | sichtbare Fundusaktionen sowie geöffnete Vorlagenauswahl auf Desktop und Mobil geprüft | erfüllt am 22.10.2026 |
| Zusatzfilter und Zeilenaktionen zugänglich | native Aufklappelemente für Filter und Aktionen per Browserinteraktion geprüft | erfüllt am 22.10.2026 |
| Funduszeilen per Tastatur zu öffnen | erster Titeleintrag fokussiert, mit Eingabetaste geöffnet und Editoraufruf geprüft | erfüllt am 22.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.6
- `npm run check`: 0 Fehler und 0 Warnungen
- gezielter Chromium-Lauf für Fundus und geöffneten Piece-Editor bei
1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne Konsolenfehler oder
horizontalen Seitenüberlauf
- Neuanlage, Zusatzfilter, Aktionsmenü, Tastaturöffnung, Glaswerte und goldener
Fokus automatisiert kontrolliert; alle erzeugten Desktop-/Mobilaufnahmen
visuell geprüft
- Produktionsbuild sowie vollständige Unit- und E2E-Matrix gemäß Auftrag nicht
ausgeführt
- `git diff --check`: erfolgreich
## Materialkorrektur 0.5.7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| Hintergrundlicht im Arbeitsbereich sichtbar | Fundus und Sendungsplanung ohne Popup in Dark/Light auf Desktop und Mobil visuell geprüft | erfüllt am 23.10.2026 |
| Popup erhält die Lichtstimmung | Editor mit leichtem `46%`-Backdrop und `6px` Blur gegen Ansicht ohne Popup verglichen | erfüllt am 23.10.2026 |
| Verschachtelte Ablaufkarten werden dunkler | berechnete RGBA-Werte von Außenglas, Abschnitt, Piece und Aktionsgruppe kontrolliert | erfüllt am 23.10.2026 |
| Graue Sonderflächen entfernt | aktive Tabs, private Recherche, Quellen-/Materialflächen und Aktionsgruppen visuell geprüft | erfüllt am 23.10.2026 |
| Schreibfelder bleiben ruhig und klar | deckende Feldfüllung, feine Kontur und unveränderter Goldfokus in beiden Editorbereichen geprüft | erfüllt am 23.10.2026 |
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 23.10.2026 |
## Ausgeführte Prüfungen Materialkorrektur 0.5.7
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- berechnete Styles vor und nach der Korrektur für App-Hintergrund, Navigation,
Ablauf-Panel, Abschnitte, Pieces, Aktionsgruppen, Dialog, Backdrop, Tabs,
Rechercheflächen und Schreibfelder kontrolliert
- gezielter Chromium-Lauf für Sendungsplanung sowie Piece-Editor mit
„Sendungstext“ und „Recherche & Quellen“ bei 1440 × 1000 und 390 × 844 Pixeln
in Light/Dark ohne Konsolenfehler oder horizontalen Seitenüberlauf
- Desktop-/Mobilaufnahmen mit und ohne Popup visuell verglichen
- vollständige Funktionstests gemäß Auftrag nicht ausgeführt
- `git diff --check`: erfolgreich
+103 -10
View File
@@ -7,6 +7,87 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen. Etappe 6 wurde nicht begonnen.
## Materialkorrektur 0.5.7
- statische Gold-/Blauviolettlichter so positioniert und verstärkt, dass sie im
eigentlichen Arbeitsbereich statt fast ausschließlich hinter der Navigation
sichtbar werden
- gemeinsame Materialtokens auf Außenglas, dunkle Innenfläche und tiefe
Ablaufkarte reduziert; verschachtelte Abschnitte, Pieces und Aktionsgruppen
werden nach innen dunkler statt heller und blaugrau
- aktive Editortabs transparent goldgetönt und private Recherche sowie
Quellen-/Materialbereiche auf transparente Nachtblauflächen zurückgeführt
- Dialog-Backdrop von starker Abdunklung und Unschärfe auf eine leichtere
Trennung reduziert, sodass die Lichtstimmung bei geöffnetem Editor erhalten
bleibt
- deckende Schreibfelder, Goldfokus, Lightmode, Mobilansicht und lesbare
Komponentenfüllungen ohne verfügbaren Blur bleiben erhalten
Die berechneten Hintergründe, Transparenzen, Filter und Begrenzungseigenschaften
wurden vor und nach der Korrektur in Chromium kontrolliert. Sendungsplanung,
Sendungstext und Recherche wurden bei 1440 × 1000 und 390 × 844 Pixeln in beiden
Themes visuell geprüft.
## Oberflächenkorrektur 0.5.6
- sichtbare statische Gold- und Blauviolettlichter hinter den halbtransparenten
Navigations- und Editorflächen
- klarere Glaswirkung in Navigation sowie Kopf, Tabs und Fuß des Piece-Editors;
Schreibflächen bleiben deckend, ruhig und goldfokussiert
- Fundus mit dauerhaft sichtbarer Suche und Neuanlage, aufklappbaren Zusatzfiltern
und inhaltsorientierten, vollständig anklickbaren Zeilen
- vorhandene Vorschaubilder beziehungsweise kleine Symbolplatzhalter sowie
gebündelte Menüs für Duplizieren und Archivieren
- bestehende Server-Actions, Filterlogik und Editor-Bereiche unverändert
Geprüft wurden `npm run check` sowie Fundus und geöffneter Piece-Editor in
Chromium bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark. Die gezielte
Kontrolle umfasste Tastaturöffnung, Menüs, Fokus, Glaswerte, Seitenüberlauf und
visuell geprüfte Screenshots. Produktionsbuild und vollständige Testmatrix wurden
gemäß Auftrag nicht ausgeführt.
## Oberflächenkorrektur 0.5.5
- dunkle Grundfläche mit sehr zurückhaltenden Gold-/Cyan-Lichtfeldern innerhalb
der Anwendung statt gleichmäßig deckender Blauflächen
- Navigation, große Arbeitsflächen und Piece-Dialog als dunkles beziehungsweise
helles Glas mit feiner Lichtkante, Unschärfe und ruhigem Schatten
- Gold für Dialogkontur, Fokus, aktive Zustände und Hauptaktionen; Cyan nur noch
für ergänzende redaktionelle Hinweise und Links
- klar abgegrenzte, weitgehend deckende Schreibfelder mit Goldfokus, Platzhaltern
und nach Textrolle abgestuften Höhen
- bestehende nachträgliche CSS-Override-Schicht entfernt und die Gestaltung in
gemeinsame Tokens und ursprüngliche Komponentenregeln zurückgeführt
Geprüft wurden Typen, Produktionsbuild sowie gezielte Chromium-Ansichten von
Eingang, Fundus, Piece-Editor, Sendungsplanung und Live-Steuerung bei 1440 × 1000
und 390 × 844 Pixeln in beiden Themes. Die vollständige Unit- und E2E-Matrix
bleibt wie beauftragt Schritt 3 vorbehalten.
## Oberflächenrelease 0.5.4
- gemeinsame Light-/Dark-Tokens für ruhige Arbeitsflächen, Cyan als
Interaktionsfarbe und Gold für die jeweilige Hauptaktion
- Darkmode im heinzlive-Farbraum mit Hintergrund `#0B1220`, Flächen `#152238`,
Text `#FFF7E8`, Sekundärtext `#A9B6C8`, Gold `#E7B957`, Cyan `#67DDE8` und
Grenzen `#30435C`
- vereinheitlichte Navigation, Formulare, Meldungen, Listen, Sendungsplan,
Live-Steuerung und Piece-Dialoge ohne Änderung der Bedienlogik
- kompaktere Planzeilen mit sichtbarem Drag-Griff und waagerechter Aktionsgruppe;
Klick-, Touch- und Tastaturalternativen bleiben erhalten
- verständliche Kontextnamen „Fundusfassung“ und „Sendungsfassung“ sowie weniger
technische Erläuterungen im redaktionellen Arbeitsfluss
- dezente Speicheraktion im gespeicherten Zustand und hervorgehobene Aktion bei
ungespeicherten Änderungen, Fehlern oder Konflikten
Für diesen Design-Release wurden `npm run check`, `npm run build` und gezielte
Chromium-Sichtkontrollen auf Eingang, Fundus, Piece-Editor, Sendungsplan und
Live-Steuerung bei 1440 × 1000 sowie 390 × 844 Pixeln in Light/Dark ausgeführt.
Konsolenfehler und horizontaler Seitenüberlauf traten dabei nicht auf. Die
vollständige Unit- und E2E-Matrix wurde für die rein visuelle Änderung bewusst
nicht erneut ausgeführt; die fachlichen Nachweise der Etappen 1 bis 5 bleiben
unverändert.
## Umgesetzter Stand Etappe 1 ## Umgesetzter Stand Etappe 1
- SvelteKit-/TypeScript-Monolith mit Node-Adapter - SvelteKit-/TypeScript-Monolith mit Node-Adapter
@@ -71,16 +152,28 @@ als bereits implementiert verstanden werden.
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht - standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- der eigenständige Piece-Editor ist als redaktionelles Arbeitsblatt aufgebaut: - Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
Titel, Fundstück, private Recherche und die getrennt gespeicherten großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
Sendungstexte stehen im Schreibfluss; Quellen, Einordnung, Präsentation und Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
Bilder begleiten ihn als Desktop-Kontextleiste oder mobile Werkzeuge Original sowie Verwendung bleiben getrennte Revisionsgrenzen
- Markdown-Felder wechseln einzeln zwischen Lesen und Bearbeiten, sodass mehrere - Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
Textbereiche gleichzeitig offen bleiben; Autosave, Konfliktschutz und vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
sichtbare Speicherfehler verwenden unverändert die Piece-Revision Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
- die Schnellaufnahme „Aufnehmen und öffnen“ leitet nach erfolgreicher Anlage - Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
direkt zum neuen Piece weiter, statt den SvelteKit-Redirect als Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
Validierungsfehler abzufangen 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 ## Umgesetzter Stand Etappe 4
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.2", "version": "0.5.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.2", "version": "0.5.7",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.2", "version": "0.5.7",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+1073 -153
View File
File diff suppressed because it is too large. Load diff
@@ -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 = 4,
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>
+264 -156
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
let { let {
@@ -19,12 +21,30 @@
error?: string; error?: string;
enhanceCommand: SubmitFunction; enhanceCommand: SubmitFunction;
} = $props(); } = $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) { function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2)); 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> </script>
<button <button
@@ -32,45 +52,77 @@
type="button" type="button"
aria-label={`${usage.title} bearbeiten und verschieben`} aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben" title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()} onclick={() => editor.showModal()}
> >
<NavIcon name="edit" /> <NavIcon name="edit" />
</button> </button>
<dialog <PieceEditorDialog
bind:this={dialog} bind:this={editor}
class="usage-editor-dialog" bind:activeSection
aria-labelledby={`usage-editor-title-${usage.id}`} labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()} onrequestclose={requestClose}
> >
<header> {#snippet header()}
<div> <p class="eyebrow">Sendungsfassung</p>
<p class="eyebrow">Sendungsspezifische Kopie</p> <label class="piece-dialog-title">
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2> <span class="sr-only">Titel</span>
</div> <textarea
<form method="dialog"> form={formId}
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button> name="title"
</form> required
</header> rows="1"
<div class="usage-dialog-scroll"> maxlength="200"
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if} aria-label="Titel"
<form bind:value={draft.title}
use:enhance={enhanceCommand} oninput={() => (dirty = true)}></textarea>
method="POST" </label>
action="?/updateUsage" <h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
class="usage-dialog-editor" {/snippet}
>
<input type="hidden" name="revision" value={revision} /> <form
<input type="hidden" name="usageId" value={usage.id} /> id={formId}
<section class="editor-section"> use:enhance={enhanceEditor}
<div class="section-title"> method="POST"
<p class="eyebrow">Grundlagen</p> action="?/updateUsage"
<h3>Titel, Dauer und Notizen</h3> 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>
<div class="editor-fields two-columns"> </div>
<label class="wide-field" <div class="piece-writing-stack">
>Titel<input name="title" required maxlength="200" value={usage.title} /></label <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 <label
>Dauer in Minuten<input >Dauer in Minuten<input
name="durationMinutes" name="durationMinutes"
@@ -81,136 +133,185 @@
value={durationInMinutes(usage.durationSeconds)} value={durationInMinutes(usage.durationSeconds)}
/></label /></label
> >
<label class="wide-field"
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div> </div>
</section> </details>
</section>
<section class="editor-section markdown-section"> <section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="section-title"> <div class="piece-panel-heading">
<p class="eyebrow">Redaktion</p> <div>
<h3>Getrennte Arbeitsbereiche</h3> <p class="eyebrow">Nicht öffentlich</p>
<h3>Recherche & Quellen</h3>
</div> </div>
<div class="markdown-grid"> <small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
<label </div>
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000" <div class="piece-writing-stack">
>{usage.intro}</textarea <MarkdownEditorField
></label name="research"
> label="Private Recherche"
<label class="private-field" placeholder="Hintergrund, Prüfung und offene Fragen"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000" rows={10}
>{usage.research}</textarea privateField
></label bind:value={draft.research}
> />
<label <MarkdownEditorField
><span>Moderationsstichpunkte</span><textarea name="ownNotes"
name="moderation" label="Interne Notizen zu dieser Verwendung"
rows="7" placeholder="Absprachen und Hinweise nur für diese Sendung"
maxlength="50000">{usage.moderation}</textarea privateField
></label bind:value={draft.ownNotes}
> />
<label </div>
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000" <section class="piece-source-summary">
>{usage.transition}</textarea <p class="eyebrow">Snapshot-Quelle</p>
></label {#if draft.presentationSourceUrl}
> <strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
<label class="wide-field" <small>{draft.presentationSourceUrl}</small>
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000" {:else}
>{usage.presentation}</textarea <p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
></label {/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>
<div class="presentation-settings"> <small>Private Recherche und Notizen werden nicht ausgegeben.</small>
<label </div>
>Darstellung<select name="presentationMode" bind:value={presentationMode}> <div class="piece-presentation-layout">
<option value="text">Text</option> <div class="piece-presentation-controls">
<option value="link">Linkvorschau</option> <MarkdownEditorField
<option value="video">Video</option> name="presentation"
</select></label label="Öffentlicher Text"
> placeholder="Text für die Publikumsausgabe"
{#if presentationMode !== 'text'} bind:value={draft.presentation}
<input />
type="hidden" <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
name="presentationSourceLabel" {#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
value={usage.presentationSourceLabel} <label
/> ><input
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} /> type="radio"
<input name="presentationMode"
type="hidden" value={mode[0]}
name="presentationSourceTimecodeSeconds" bind:group={draft.presentationMode}
value={usage.presentationSourceTimecodeSeconds ?? ''} />{mode[1]}</label
/> >
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} /> {/each}
<div class="presentation-source-summary wide-field"> </div>
<span>Ausgewählte Quelle</span> <input
<strong type="hidden"
>{usage.presentationSourceLabel || name="presentationSourceLabel"
usage.presentationSourceUrl || value={draft.presentationSourceLabel}
'Keine Quelle'}</strong />
<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"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={draft.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}></textarea></label
> >
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
</div> </div>
<label {:else}
>Titel<input <input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
name="presentationTitle" <input
maxlength="300" type="hidden"
value={usage.presentationTitle} name="presentationDescription"
/></label value={draft.presentationDescription}
> />
<label <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
>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
>
{/if} {/if}
</div> </div>
</section> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<button class="primary-button" type="submit">Verwendung speichern</button> <h4>{draft.presentationTitle || draft.title}</h4>
</form> {#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
<section class="usage-dialog-actions" aria-label="Weitere Aktionen"> {draft.presentation || 'Noch kein öffentlicher Text.'}
<div> </p>
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage"> {:else}
<input type="hidden" name="revision" value={revision} /> {#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
<input type="hidden" name="usageId" value={usage.id} /> <p>
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId} {draft.presentationDescription ||
>Aus Fundus aktualisieren</button draft.presentationSourceUrl ||
> 'Noch keine Vorschauangaben.'}
</form> </p>
<small {/if}
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small </aside>
>
</div> </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="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} /> <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>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} /><input
<input type="hidden" name="usageId" value={usage.id} /> type="hidden"
<input type="hidden" name="position" value="9999" /> name="usageId"
value={usage.id}
/><input type="hidden" name="position" value="9999" />
<label <label
>Ziel<select name="sectionId"> ><span class="sr-only">Ziel</span><select name="sectionId"
<option value="">Reservebank</option> ><option value="">Reservebank</option>{#each sections as target (target.id)}<option
{#each sections as target (target.id)} value={target.id}
<option value={target.id} selected={target.id === sectionId}>{target.name}</option> selected={target.id === sectionId}>{target.name}</option
{/each} >{/each}</select
</select></label ></label
> >
<button class="secondary-button" type="submit">Verschieben</button> <button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
</form> </form>
<form <form
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
@@ -219,10 +320,17 @@
onsubmit={(event) => onsubmit={(event) =>
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()} !confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
> >
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} /><input
<input type="hidden" name="usageId" value={usage.id} /> type="hidden"
<button class="text-button danger-text" type="submit">Entfernen</button> name="usageId"
value={usage.id}
/>
<button class="text-button danger-text" type="submit" disabled={dirty}>Entfernen</button>
</form> </form>
</section> </div>
</div> <div class="piece-dialog-primary-action">
</dialog> {#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 { export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title'); let title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
const url = cleanText(urlValue, 2_000, 'source'); const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES'); 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 id = createPiece('blank');
const piece = getPiece(id)!; const piece = getPiece(id)!;
savePiece(id, piece.revision, { 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 type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } from '#lib/server/content.js'; import { captureInbox, listPieces } from '#lib/server/content.js';
@@ -12,9 +12,9 @@ export const actions = {
capture: async ({ locals, request }) => { capture: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
let id: string;
try { 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) { } catch (cause) {
const code = cause instanceof Error ? cause.message : ''; const code = cause instanceof Error ? cause.message : '';
return fail(400, { return fail(400, {
@@ -26,6 +26,5 @@ export const actions = {
: 'Ein Titel ist erforderlich.' : 'Ein Titel ist erforderlich.'
}); });
} }
redirect(303, `/fundus/${id}`);
} }
} satisfies Actions; } satisfies Actions;
+6 -3
View File
@@ -17,11 +17,14 @@
<h2>Was soll nicht verloren gehen?</h2> <h2>Was soll nicht verloren gehen?</h2>
</div> </div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#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"> <form method="POST" action="?/capture" class="capture-form">
<label <label
>Titel oder Gedanke<input >Gedanke <span>optional bei URL</span><input
name="title" name="title"
required
maxlength="200" maxlength="200"
value={form?.title ?? ''} value={form?.title ?? ''}
placeholder="Kurze Idee festhalten" placeholder="Kurze Idee festhalten"
@@ -36,7 +39,7 @@
placeholder="https://…" placeholder="https://…"
/></label /></label
> >
<button class="primary-button" type="submit">Aufnehmen und öffnen</button> <button class="primary-button" type="submit">Im Eingang ablegen</button>
</form> </form>
</section> </section>
{#if data.pieces.length} {#if data.pieces.length}
+120 -102
View File
@@ -59,101 +59,112 @@
<h1>Fundus</h1> <h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div> </div>
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}> <div class="fundus-heading-actions">
<strong>{visiblePieces.length}</strong><span>Inhalte</span> <div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div>
<details class="template-picker">
<summary><span aria-hidden="true">+</span> Neues Piece</summary>
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button
>
</form>
{/each}
</div>
</details>
</div> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<details class="template-picker">
<summary>Neues Piece anlegen</summary>
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button
>
</form>
{/each}
</div>
</details>
<form <form
id="library-filters" id="library-filters"
class="library-filters" class="library-filters"
method="GET" method="GET"
aria-label="Fundus durchsuchen und filtern" aria-label="Fundus durchsuchen und filtern"
> >
<label class="search-field" <div class="fundus-search-row">
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input <label class="search-field"
name="q" ><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
type="search" name="q"
value={data.filters.query} type="search"
placeholder="Titel, Quelle oder Thema suchen" value={data.filters.query}
/></label placeholder="Titel, Quelle oder Thema suchen"
> /></label
<label >
><span>Status</span><select name="status" <button class="secondary-button" type="submit">Suchen</button>
><option value="">Alle</option><option <details class="filter-picker">
value="ready" <summary><span aria-hidden="true">≡</span> Weitere Filter</summary>
selected={data.filters.status === 'ready'}>Sendefertig</option <div class="filter-panel">
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option <label
></select ><span>Status</span><select name="status"
></label ><option value="">Alle</option><option
> value="ready"
<label selected={data.filters.status === 'ready'}>Sendefertig</option
><span>Format</span><select name="format" ><option value="preparing" selected={data.filters.status === 'preparing'}
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option >In Arbeit</option
value={item.id} ></select
selected={data.filters.formatId === item.id}>{item.name}</option ></label
>{/each}</select >
></label <label
> ><span>Format</span><select name="format"
<label ><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
><span>Thema</span><select name="topic" value={item.id}
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option selected={data.filters.formatId === item.id}>{item.name}</option
value={item.id} >{/each}</select
selected={data.filters.topicId === item.id}>{item.name}</option ></label
>{/each}</select >
></label <label
> ><span>Thema</span><select name="topic"
<label ><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
><span>Abschnitt</span><select name="category" value={item.id}
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option selected={data.filters.topicId === item.id}>{item.name}</option
value={item.id} >{/each}</select
selected={data.filters.categoryId === item.id}>{item.name}</option ></label
>{/each}</select >
></label <label
> ><span>Abschnitt</span><select name="category"
<button class="secondary-button" type="submit">Anwenden</button> ><option value="">Alle</option
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button> >{#each data.taxonomy.categories as item (item.id)}<option
value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select
></label
>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
<div class="filter-actions">
<button class="secondary-button" type="submit">Filter anwenden</button>
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
</div>
</div>
</details>
</div>
</form> </form>
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
<label class="filter-checkbox">
<input
type="checkbox"
name="includeUsed"
value="1"
form="library-filters"
checked={showUsedPieces}
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
/>
<span>Auch verwendete Inhalte zeigen</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
name="includePlanned"
value="1"
form="library-filters"
checked={showPlannedPieces}
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
/>
<span>Auch eingeplante Inhalte zeigen</span>
</label>
</div>
{#if visiblePieces.length} {#if visiblePieces.length}
<div class="piece-grid"> <div class="piece-grid">
@@ -166,9 +177,12 @@
class="piece-thumbnail" class="piece-thumbnail"
src={`/media/${piece.mediaId}`} src={`/media/${piece.mediaId}`}
alt="" alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true" />{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
>S</span <svg viewBox="0 0 24 24" fill="none">
>{/if} <path d="M4.5 6.5h15v11h-15zM7.5 14l3-3 2.2 2.2 1.8-1.7 2.5 2.5" />
<circle cx="15.8" cy="9.2" r="1.2" />
</svg>
</span>{/if}
<div class="piece-primary"> <div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2> <h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p> <p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
@@ -193,22 +207,26 @@
></span ></span
> >
</div> </div>
<div class="piece-actions"> <details class="piece-actions">
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a> <summary aria-label={`Aktionen für ${piece.title}`}
<form method="POST" action="?/duplicate"> ><span aria-hidden="true">•••</span></summary
<input type="hidden" name="pieceId" value={piece.id} /><button >
class="text-button" <div class="piece-actions-popover">
type="submit">Duplizieren</button <form method="POST" action="?/duplicate">
> <input type="hidden" name="pieceId" value={piece.id} /><button
</form> class="text-button"
<form method="POST" action="?/archive"> type="submit">Duplizieren</button
<input type="hidden" name="pieceId" value={piece.id} /><input >
type="hidden" </form>
name="revision" <form method="POST" action="?/archive">
value={piece.revision} <input type="hidden" name="pieceId" value={piece.id} /><input
/><button class="text-button danger-text" type="submit">Archivieren</button> type="hidden"
</form> name="revision"
</div> value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
</div>
</details>
</article> </article>
{/each} {/each}
</div> </div>
+369 -497
View File
@@ -2,25 +2,24 @@
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onDestroy, tick, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte'; import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.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'; import type { PageProps } from './$types';
type ContextPanel = 'sources' | 'classification' | 'presentation';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece }))); let draft = $state(untrack(() => ({ ...data.piece })));
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2)))); let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
let revision = $state(untrack(() => data.piece.revision)); let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source })))); let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let rendered = $state(untrack(() => ({ ...data.rendered }))); let rendered = $state(untrack(() => ({ ...data.rendered })));
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle'); let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert'); 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 sourceImageUnavailable = $state(false);
let editorDialog: { showModal: () => void; close: () => void };
let editorForm: HTMLFormElement; let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement; let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement; let saveButton: HTMLButtonElement;
@@ -41,13 +40,34 @@
let sourcePreviewImage = $derived( let sourcePreviewImage = $derived(
draft.presentationImageUrl draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id }) ? 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 pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
let contextTrigger: HTMLElement | null = null;
const statusLabels = { const statusLabels = {
inbox: 'Eingang', inbox: 'Eingang',
@@ -55,6 +75,7 @@
ready: 'Sendefertig' ready: 'Sendefertig'
}; };
onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer)); onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => { beforeNavigate((navigation) => {
if (allowNavigation) return; if (allowNavigation) return;
@@ -92,6 +113,23 @@
editorForm.requestSubmit(saveButton); 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() { function addSource() {
sources.push({ label: '', url: '', timecodeSeconds: null }); sources.push({ label: '', url: '', timecodeSeconds: null });
scheduleSave(); scheduleSave();
@@ -119,23 +157,6 @@
if (!draft.presentationImageManual) draft.presentationImageUrl = ''; 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 enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation; const submittedGeneration = generation;
saving = true; saving = true;
@@ -176,259 +197,82 @@
</script> </script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head> <svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:window onkeydown={handleEscape} />
<div class="page-wrap editor-page worksheet-page"> <div class="piece-editor-route">
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error route-editor-error" role="alert">
{form.error}
<form </div>{/if}
bind:this={editorForm} <PieceEditorDialog
use:enhance={enhanceSave} bind:this={editorDialog}
method="POST" bind:activeSection
action="?/save" labelledby="piece-editor-title"
class="piece-editor worksheet-editor" onrequestclose={requestClose}
oninput={scheduleSave}
onchange={scheduleSave}
> >
<input type="hidden" name="revision" value={revision} /> {#snippet header()}
<header class="worksheet-header"> <p class="eyebrow">Fundusfassung</p>
<div class="worksheet-heading"> <label class="piece-dialog-title">
<a class="back-link" href={resolve('/fundus')}>← Fundus</a> <span class="sr-only">Titel</span>
<h1 class="sr-only">{draft.title}</h1> <textarea
<label class="worksheet-title"> form="piece-editor-form"
<span class="sr-only">Titel</span> name="title"
<textarea required
name="title" rows="1"
required maxlength="200"
rows="1" aria-label="Titel"
maxlength="200" bind:value={draft.title}
aria-label="Titel" oninput={scheduleSave}></textarea>
bind:value={draft.title}></textarea> </label>
</label> <h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
</div> {/snippet}
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
<nav class="mobile-editor-tools" aria-label="Piece-Werkzeuge"> <form
<button bind:this={editorForm}
type="button" id="piece-editor-form"
aria-expanded={mobileContext === 'sources'} use:enhance={enhanceSave}
onclick={(event) => openContext(event, 'sources')}>Quellen</button method="POST"
> action="?/save"
<button class="piece-dialog-form"
type="button" oninput={scheduleSave}
aria-expanded={mobileContext === 'classification'} onchange={scheduleSave}
onclick={(event) => openContext(event, 'classification')}>Einordnung</button >
> <input type="hidden" name="revision" value={revision} />
<button
type="button"
aria-expanded={mobileContext === 'presentation'}
onclick={(event) => openContext(event, 'presentation')}>Präsentation</button
>
<a href="#piece-media">Bilder</a>
</nav>
<div class="editor-workspace"> <section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<main class="editorial-worksheet" aria-label="Redaktionelles Arbeitsblatt"> <div class="piece-panel-heading">
<section
class:has-image={sourcePreviewImage && !sourceImageUnavailable}
class="source-feature"
>
{#if sourcePreviewImage && !sourceImageUnavailable}
<img src={sourcePreviewImage} alt="" onerror={() => (sourceImageUnavailable = true)} />
{/if}
<div> <div>
<p class="eyebrow">Fundstück</p> <p class="eyebrow">Für die Moderation</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>
<h2>Sendungstext</h2> <h2>Sendungstext</h2>
<p>Einleitung, Moderation und Übergang bleiben getrennt gespeichert.</p>
</div> </div>
<div class="script-sections"> </div>
<EditorialMarkdownField <div class="piece-writing-stack">
name="intro" <MarkdownEditorField
label="Einleitung" name="intro"
emptyText="Wie beginnt dieses Thema in der Sendung?" label="Einleitung"
bind:value={draft.intro} placeholder="Wie beginnt dieses Thema in der Sendung?"
html={rendered.intro} bind:value={draft.intro}
/> />
<EditorialMarkdownField <MarkdownEditorField
name="moderationNotes" name="moderationNotes"
label="Moderationsstichpunkte" label="Moderationsstichpunkte"
emptyText="Die wichtigsten Punkte für die Moderation notieren" placeholder="Argumente, Fragen und Ablaufpunkte"
bind:value={draft.moderationNotes} rows={10}
html={rendered.moderation} bind:value={draft.moderationNotes}
/> />
<EditorialMarkdownField <MarkdownEditorField
name="transitionText" name="transitionText"
label="Überleitung" label="Überleitung"
emptyText="Den Übergang zum nächsten Thema vorbereiten" placeholder="Wie geht es zum nächsten Thema weiter?"
bind:value={draft.transitionText} bind:value={draft.transitionText}
html={rendered.transition} />
/> </div>
</div> <details class="piece-meta-details">
</section> <summary>
</main> <span>Status, Dauer und Einordnung</span>
<small
<aside >{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
class:context-open={mobileContext}
class="editor-context"
aria-label="Werkzeuge zum Piece"
>
<section
class:mobile-active={mobileContext === 'sources'}
class="context-card source-editor"
aria-labelledby="source-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Fundstück</p>
<h2 id="source-context-title">Quellen</h2>
</div>
<button
class="context-close"
data-context-close="sources"
type="button"
aria-label="Quellen schließen"
onclick={closeContext}>×</button
> >
</header> </summary>
{#if sources.length} <div class="piece-meta-grid">
<div class="source-list">
{#each sources as source, index (index)}
<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
bind:value={source.label}
/></label
>
<label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
>
<label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
>
<button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>
{/each}
</div>
{:else}
<p class="context-empty">Noch keine Quelle hinterlegt.</p>
{/if}
<button class="secondary-button context-wide-action" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</section>
<section
class:mobile-active={mobileContext === 'classification'}
class="context-card classification-editor"
aria-labelledby="classification-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Überblick</p>
<h2 id="classification-context-title">Einordnung</h2>
</div>
<button
class="context-close"
data-context-close="classification"
type="button"
aria-label="Einordnung schließen"
onclick={closeContext}>×</button
>
</header>
<div class="classification-summary">
<strong>{statusLabels[draft.status as keyof typeof statusLabels]}</strong>
<span>{durationMinutes || 0} Min.</span>
<span
>{data.taxonomy.formats.find((item) => item.id === draft.formatId)?.name ??
'Ohne Format'}</span
>
</div>
{#if draft.summary}<p class="context-summary-copy">{draft.summary}</p>{/if}
<div class="context-tags">
{#each data.taxonomy.topics.filter( (item) => draft.topicIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
{#each data.taxonomy.categories.filter( (item) => draft.categoryIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
</div>
<button
class="text-button context-edit-toggle"
type="button"
aria-expanded={classificationEditing}
onclick={() => (classificationEditing = !classificationEditing)}
>{classificationEditing ? 'Einordnung schließen' : 'Einordnung bearbeiten'}</button
>
<div class="context-fields" hidden={!classificationEditing}>
<label <label
>Status<select name="status" bind:value={draft.status} >Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing" ><option value="inbox">Eingang</option><option value="preparing"
@@ -454,11 +298,12 @@
>{/each}</select >{/each}</select
></label ></label
> >
<label <label class="wide-field"
>Kurzbeschreibung<textarea >Kurzbeschreibung<textarea
name="summary" name="summary"
rows="3" rows="3"
maxlength="2000" maxlength="2000"
placeholder="Worum geht es in wenigen Sätzen?"
bind:value={draft.summary}></textarea></label bind:value={draft.summary}></textarea></label
> >
<fieldset> <fieldset>
@@ -488,57 +333,93 @@
</div> </div>
</fieldset> </fieldset>
</div> </div>
</section> </details>
</section>
<section <section class="piece-editor-panel" hidden={activeSection !== 'research'}>
class:mobile-active={mobileContext === 'presentation'} <div class="piece-panel-heading">
class="context-card presentation-editor" <div>
aria-labelledby="presentation-context-title" <p class="eyebrow">Nicht öffentlich</p>
> <h2>Recherche & Quellen</h2>
<header class="context-card-heading"> </div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<MarkdownEditorField
name="researchText"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.researchText}
/>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div> <div>
<p class="eyebrow">Publikum</p> <p class="eyebrow">Fundstellen</p>
<h2 id="presentation-context-title">Präsentation</h2> <h3>Quellen</h3>
</div> </div>
<button <button class="secondary-button" type="button" onclick={addSource}
class="context-close" >Quelle hinzufügen</button
data-context-close="presentation"
type="button"
aria-label="Präsentation schließen"
onclick={closeContext}>×</button
>
</header>
<div class="audience-preview">
<span
>{draft.presentationMode === 'text'
? 'Text'
: draft.presentationMode === 'link'
? 'Linkvorschau'
: 'Video'}</span
>
<strong>{draft.presentationTitle || draft.title}</strong>
{#if draft.presentationMode === 'text' && rendered.presentation}<MarkdownPreview
html={rendered.presentation}
/>{:else if draft.presentationDescription}<p>
{draft.presentationDescription}
</p>{:else}<p>Noch keine Vorschauangaben.</p>{/if}
</div>
<div class="presentation-card-actions">
<button
class="text-button"
type="button"
aria-expanded={presentationEditing}
onclick={() => (presentationEditing = !presentationEditing)}
>{presentationEditing ? 'Einstellungen schließen' : 'Präsentation bearbeiten'}</button
>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">Präsentation testen</a
> >
</div> </div>
<div class="presentation-controls" hidden={!presentationEditing}> {#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
placeholder="Name der Quelle"
bind:value={source.label}
/></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
placeholder="Optional"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
</section>
</section>
<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"> <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label {#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input ><input
@@ -549,129 +430,178 @@
/>{mode[1]}</label />{mode[1]}</label
>{/each} >{/each}
</div> </div>
{#if draft.presentationMode === 'text'} <div class="presentation-settings">
<input <label
type="hidden" >Quelle<select
name="presentationSourcePosition" name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''} bind:value={draft.presentationSourcePosition}
/> onchange={presentationSourceChanged}
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} /> ><option value={null}>Quelle auswählen</option
<input >{#each sources as source, index (index)}{#if source.url}<option value={index}
type="hidden" >{source.label || source.url}</option
name="presentationDescription" >{/if}{/each}</select
value={draft.presentationDescription} ></label
/> >
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <label
<EditorialMarkdownField >Titel<input
name="presentationText" name="presentationTitle"
label="Präsentationstext" maxlength="300"
emptyText="Text für die Publikumsausgabe schreiben" placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationText} bind:value={draft.presentationTitle}
html={rendered.presentation} oninput={() => (draft.presentationTitleManual = true)}
rows={8} /></label
/> >
{:else} <label
<input type="hidden" name="presentationText" value={draft.presentationText} /> >Bild-URL<input
<div class="presentation-settings"> name="presentationImageUrl"
<label type="url"
>Quelle<select maxlength="2000"
name="presentationSourcePosition" placeholder="https://…"
bind:value={draft.presentationSourcePosition} bind:value={draft.presentationImageUrl}
onchange={presentationSourceChanged} oninput={() => (draft.presentationImageManual = true)}
><option value={null}>Quelle auswählen</option /></label
>{#each sources as source, index (index)}{#if source.url}<option value={index} >
>{source.label || source.url}</option <label class="wide-field"
>{/if}{/each}</select >Anreißer<textarea
></label name="presentationDescription"
rows="3"
maxlength="1000"
placeholder="Kurzer Text zur Vorschau"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}</small
> >
<label
>Titel<input
name="presentationTitle"
maxlength="300"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}</small
>
</div>
</div> </div>
{/if} </div>
</div> </div>
<input <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
type="hidden" <PresentationDisplay
name="presentationTitleManual" content={previewContent}
value={draft.presentationTitleManual ? '1' : '0'} imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
/> />
<input </aside>
type="hidden" </div>
name="presentationDescriptionManual" <input
value={draft.presentationDescriptionManual ? '1' : '0'} type="hidden"
/> name="presentationTitleManual"
<input value={draft.presentationTitleManual ? '1' : '0'}
type="hidden" />
name="presentationImageManual" <input
value={draft.presentationImageManual ? '1' : '0'} type="hidden"
/> name="presentationDescriptionManual"
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> value={draft.presentationDescriptionManual ? '1' : '0'}
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} /> />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} /> <input
<input type="hidden"
type="hidden" name="presentationImageManual"
name="presentationMetadataFetchedAtUtc" value={draft.presentationImageManual ? '1' : '0'}
value={draft.presentationMetadataFetchedAtUtc ?? ''} />
/> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
</section> <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="context-card asset-summary"> <section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
<div> <div class="piece-subsection-heading">
<p class="eyebrow">Material</p> <div>
<h2>Bilder</h2> <p class="eyebrow">Private Dateien</p>
<p> <h3>Material</h3>
{data.piece.media.length </div>
? `${data.piece.media.length} gespeichert` <small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
: 'Noch keine Bilder'} </div>
</p> {#if data.piece.media.length}<div class="media-grid">
</div> {#each data.piece.media as media (media.id)}<figure>
<a class="text-link" href="#piece-media">Bilder verwalten ↓</a> <img src={`/media/${media.id}`} alt={media.altText} />
</section> <figcaption>
</aside> <span>{media.altText || media.originalName}</span>
</div> <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"> {#snippet footer()}
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span> <div class="piece-dialog-secondary-actions">
<button bind:this={saveButton} class="primary-button" type="submit" disabled={saving} <form method="POST" action="?/duplicate">
>Jetzt speichern</button <button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
> </form>
</div> <form method="POST" action="?/archive">
</form> <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={saveState === 'dirty' ||
saveState === 'error' ||
saveState === 'conflict'}
class:secondary-button={saveState === 'idle' ||
saveState === 'saved' ||
saveState === 'saving'}
type="submit"
form="piece-editor-form"
disabled={saving}
>{saveState === 'error' || saveState === 'conflict'
? 'Erneut speichern'
: 'Speichern'}</button
>
</div>
{/snippet}
</PieceEditorDialog>
<form <form
bind:this={previewForm} bind:this={previewForm}
@@ -682,62 +612,4 @@
> >
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
</form> </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>
</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> </div>
+1 -1
View File
@@ -533,7 +533,7 @@
ondragend={finishDrag} ondragend={finishDrag}
> >
<strong>{usage.title}</strong><small <strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · sendungsspezifische Kopie</small >{formatClock(usage.durationSeconds)}</small
> >
</div> </div>
<div class="row-actions"> <div class="row-actions">
+38 -36
View File
@@ -31,11 +31,13 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
} }
async function openEditorialField(page: Page, heading: string) { async function openEditorialField(page: Page, heading: string) {
const field = page.locator('.editorial-field').filter({ if (heading.includes('Recherche')) {
has: page.getByRole('heading', { name: heading, exact: true }) await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
}); } else if (heading.includes('Präsentation')) {
await field.locator('.editorial-edit-button').click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
return field; } else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
}
} }
async function login(page: Page, email = adminEmail, password = adminPassword) { 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 expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl); await expect(page).toHaveURL(pieceUrl);
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); await page.getByText('Status, Dauer und Einordnung').click();
await page await page
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.'); .fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check(); 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 page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte'); await openEditorialField(page, 'Moderationsstichpunkte');
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.'); await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
await expect(page.getByLabel('Private Recherche')).toBeVisible(); await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
await openEditorialField(page, 'Präsentationstext'); await openEditorialField(page, 'Präsentationstext');
await page await page
.getByLabel('Präsentationstext') .getByLabel('Öffentlicher Text')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); .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(); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last(); const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag'); 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 expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200); await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await page await openEditorialField(page, 'Präsentationstext');
.locator('.editorial-field') await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
.filter({ has: page.getByRole('heading', { name: 'Präsentationstext', exact: true }) }) await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
.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 page.reload(); await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); 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 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( await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.' '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 concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert'); await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); 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 await concurrent
.getByLabel('Kurzbeschreibung') .getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.'); .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 concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); 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( await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.' '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 page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark'); await setTheme(page, 'dark');
await page.reload(); 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 expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Quellen schließen' }).click();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'), 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.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.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Aufnehmen und öffnen' }).click(); await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
await expect(page.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute( await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
'href', await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ' 'https://youtu.be/dQw4w9WgXcQ'
); );
await expect(page.getByLabel('Status')).toBeHidden(); await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Eigene Gedanken & Recherche'); await openEditorialField(page, 'Private Recherche');
await page await page
.getByLabel('Private Recherche') .getByLabel('Private Recherche')
.fill('Warum funktioniert der Einstieg so unmittelbar?'); .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 await page
.getByLabel('Moderationsstichpunkte') .getByLabel('Moderationsstichpunkte')
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.'); .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.getByLabel('Video').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup'); 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; const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute( await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src', '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.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Video')).toBeVisible(); 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 expect(page.getByLabel('Private Recherche')).toBeVisible();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
@@ -1201,21 +1201,21 @@ test('link preview uses one existing source, persists manual fields and isolates
page page
}, testInfo) => { }, testInfo) => {
await page.goto('/fundus/piece-console'); 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.getByLabel('Linkvorschau').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau'); 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 page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); 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('Linkvorschau')).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue( await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau' 'Manuell korrigierte Linkvorschau'
); );
const previewPromise = page.waitForEvent('popup'); 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; const preview = await previewPromise;
await preview.waitForLoadState('networkidle'); await preview.waitForLoadState('networkidle');
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible(); 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 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 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 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) => const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview') response.url().includes('?/loadPreview')
); );