6 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
hnzio 25da870601 Release 0.5.2 2026-10-22 13:51:30 +02:00
16 changed files with 3002 additions and 783 deletions

No files matched your search

+117
View File
@@ -189,3 +189,120 @@
Pixeln visuell kontrolliert Pixeln visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` - `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `bash -n start.sh` und `git diff --check`: erfolgreich - `bash -n start.sh` und `git diff --check`: erfolgreich
## Redaktionelles Piece-Arbeitsblatt
| Kriterium | Nachweis | Stand |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Piece-Arbeitsblatt
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
1 von 1 Browsertest erfolgreich
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich
## 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
+12
View File
@@ -239,6 +239,18 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation. ohne interne Anwendungsnavigation.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen ## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
+103
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,6 +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
- Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
Original sowie Verwendung bleiben getrennte Revisionsgrenzen
- Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
- Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
Vorschau stehen gemeinsam im Präsentationsbereich
- der Dialog bleibt innerhalb der Desktop-Fensterhöhe, hält Kopf,
Bereichsnavigation und Aktionen sichtbar und nutzt mobil die volle
Bildschirmfläche; Bereichswechsel hängen Eingaben nicht aus und merken die
jeweilige Scrollposition
- die Schnellaufnahme akzeptiert Gedanke oder URL, bleibt nach dem Ablegen im
Eingang und öffnet die Ausarbeitung erst über eine bewusste Aktion
Für diese gezielte Überarbeitung wurden `npm run check` sowie kurze
Chromium-Sichtkontrollen des Original- und Verwendungseditors bei 1440 × 1000
und 390 × 844 Pixeln ausgeführt. Dabei waren Dialoggrenzen, feste Bedienbereiche,
alle drei Arbeitsbereiche und horizontaler Überlauf unauffällig. Die vollständige
Unit-, E2E- und Build-Matrix ist gemäß Auftrag noch offen.
## Umgesetzter Stand Etappe 4 ## Umgesetzter Stand Etappe 4
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.1", "version": "0.5.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.1", "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.1", "version": "0.5.7",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+1654 -150
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,81 @@
<script lang="ts">
import { tick } from 'svelte';
let {
name,
label,
value = $bindable(),
rows = 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, {
+2 -2
View File
@@ -1,4 +1,4 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import 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';
@@ -14,7 +14,7 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
try { try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || '')); const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`); 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, {
+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>
+459 -360
View File
@@ -2,8 +2,11 @@
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, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte'; import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -12,8 +15,11 @@
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 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;
@@ -26,9 +32,50 @@
saveState === 'error' || saveState === 'error' ||
saveState === 'conflict' saveState === 'conflict'
); );
let featuredSource = $derived(
draft.presentationSourcePosition === null
? (sources[0] ?? null)
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
);
let sourcePreviewImage = $derived(
draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: ''
);
let previewContent = $derived<PresentationContent>({
mode: draft.presentationMode,
title: draft.title,
html: rendered.presentation,
source: featuredSource
? {
label: featuredSource.label || featuredSource.url,
url: featuredSource.url,
timecodeSeconds: featuredSource.timecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationImageUrl,
siteName: draft.presentationSiteName,
provider: draft.presentationProvider as PresentationContent['provider'],
providerId: draft.presentationProviderId,
embedUrl:
draft.presentationProvider === 'youtube' && draft.presentationProviderId
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: ''
});
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onMount(() => editorDialog.showModal());
onDestroy(() => clearTimeout(timer)); onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => { beforeNavigate((navigation) => {
if (allowNavigation) return; if (allowNavigation) return;
@@ -66,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();
@@ -87,6 +151,7 @@
draft.presentationMetadataFetchedAtUtc = null; draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = ''; draft.presentationProvider = '';
draft.presentationProviderId = ''; draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = ''; if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = ''; if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = ''; if (!draft.presentationImageManual) draft.presentationImageUrl = '';
@@ -133,319 +198,411 @@
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head> <svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<div class="page-wrap editor-page"> <div class="piece-editor-route">
<header class="editor-header"> {#if form?.error}<div class="form-message error route-editor-error" role="alert">
<div> {form.error}
<a class="back-link" href={resolve('/fundus')}>← Fundus</a> </div>{/if}
<h1>{draft.title}</h1> <PieceEditorDialog
</div> bind:this={editorDialog}
<div bind:activeSection
class:problem={saveState === 'error' || saveState === 'conflict'} labelledby="piece-editor-title"
class="save-indicator" onrequestclose={requestClose}
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
bind:this={editorForm}
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
oninput={scheduleSave}
onchange={scheduleSave}
> >
<input type="hidden" name="revision" value={revision} /> {#snippet header()}
<section class="editor-section editor-basics"> <p class="eyebrow">Fundusfassung</p>
<div class="section-title"> <label class="piece-dialog-title">
<p class="eyebrow">Grundlagen</p> <span class="sr-only">Titel</span>
<h2>Worum geht es?</h2> <textarea
</div> form="piece-editor-form"
<div class="editor-fields two-columns"> name="title"
<label class="wide-field" required
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label rows="1"
> maxlength="200"
<label aria-label="Titel"
>Status<select name="status" bind:value={draft.status} bind:value={draft.title}
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option oninput={scheduleSave}></textarea>
><option value="ready">Sendefertig</option></select </label>
></label <h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
> {/snippet}
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
<section class="editor-section taxonomy-editor"> <form
<div class="section-title"> bind:this={editorForm}
<p class="eyebrow">Einordnung</p> id="piece-editor-form"
<h2>Themen und Abschnitte</h2> use:enhance={enhanceSave}
</div> method="POST"
<fieldset> action="?/save"
<legend>Themen</legend> class="piece-dialog-form"
<div class="choice-grid"> oninput={scheduleSave}
{#each data.taxonomy.topics as item (item.id)}<label onchange={scheduleSave}
><input >
type="checkbox" <input type="hidden" name="revision" value={revision} />
name="topicIds"
value={item.id}
bind:group={draft.topicIds}
/>{item.name}</label
>{/each}
</div>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</section>
<section class="editor-section markdown-section"> <section class="piece-editor-panel" hidden={activeSection !== 'script'}>
<div class="section-title"> <div class="piece-panel-heading">
<p class="eyebrow">Redaktion</p> <div>
<h2>Getrennte Arbeitsbereiche</h2> <p class="eyebrow">Für die Moderation</p>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small> <h2>Sendungstext</h2>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
</div>
</section>
<section class="editor-section presentation-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Präsentation</p>
<h2>Darstellung in heinzlive</h2>
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
</div>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
target="_blank"
rel="noopener">Präsentation testen</a
>
</div>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input type="hidden" name="presentationDescription" value={draft.presentationDescription} />
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
<label class="presentation-text-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label class="wide-field"
>Quelle<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
>
<option value={null}>Quelle auswählen</option>
{#each sources as source, index (index)}
{#if source.url}<option value={index}>{source.label || source.url}</option>{/if}
{/each}
</select></label
>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
rows="3"
maxlength="1000"
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions wide-field">
<button
class="secondary-button"
type="button"
onclick={() => previewForm.requestSubmit()}
disabled={actionsBlocked || draft.presentationSourcePosition === null}
>Vorschau laden</button
>
<small>
{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}
</small>
</div> </div>
</div> </div>
{/if} <div class="piece-writing-stack">
<input <MarkdownEditorField
type="hidden" name="intro"
name="presentationTitleManual" label="Einleitung"
value={draft.presentationTitleManual ? '1' : '0'} placeholder="Wie beginnt dieses Thema in der Sendung?"
/> bind:value={draft.intro}
<input />
type="hidden" <MarkdownEditorField
name="presentationDescriptionManual" name="moderationNotes"
value={draft.presentationDescriptionManual ? '1' : '0'} label="Moderationsstichpunkte"
/> placeholder="Argumente, Fragen und Ablaufpunkte"
<input rows={10}
type="hidden" bind:value={draft.moderationNotes}
name="presentationImageManual" />
value={draft.presentationImageManual ? '1' : '0'} <MarkdownEditorField
/> name="transitionText"
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> label="Überleitung"
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} /> placeholder="Wie geht es zum nächsten Thema weiter?"
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} /> bind:value={draft.transitionText}
<input />
type="hidden"
name="presentationMetadataFetchedAtUtc"
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
</div> </div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button <details class="piece-meta-details">
> <summary>
</div> <span>Status, Dauer und Einordnung</span>
{#if sources.length}<div class="source-list"> <small
{#each sources as source, index (index)}<div class="source-row"> >{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
>
</summary>
<div class="piece-meta-grid">
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing"
>In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
type="number"
min="0"
max="1440"
step="any"
bind:value={durationMinutes}
/></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="3"
maxlength="2000"
placeholder="Worum geht es in wenigen Sätzen?"
bind:value={draft.summary}></textarea></label
>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
{#each data.taxonomy.topics as item (item.id)}<label
><input
type="checkbox"
name="topicIds"
value={item.id}
bind:group={draft.topicIds}
/>{item.name}</label
>{/each}
</div>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</div>
</details>
</section>
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
<div class="piece-panel-heading">
<div>
<p class="eyebrow">Nicht öffentlich</p>
<h2>Recherche & Quellen</h2>
</div>
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
</div>
<MarkdownEditorField
name="researchText"
label="Private Recherche"
placeholder="Hintergrund, Prüfung und offene Fragen"
rows={10}
privateField
bind:value={draft.researchText}
/>
<section class="piece-source-editor">
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Fundstellen</p>
<h3>Quellen</h3>
</div>
<button class="secondary-button" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
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">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
type="radio"
name="presentationMode"
value={mode[0]}
bind:group={draft.presentationMode}
/>{mode[1]}</label
>{/each}
</div>
<div class="presentation-settings">
<label <label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label >Quelle<select
><label name="presentationSourcePosition"
>URL<input bind:value={draft.presentationSourcePosition}
name="sourceUrl" onchange={presentationSourceChanged}
><option value={null}>Quelle auswählen</option
>{#each sources as source, index (index)}{#if source.url}<option value={index}
>{source.label || source.url}</option
>{/if}{/each}</select
></label
>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
oninput={() => (draft.presentationTitleManual = true)}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url" type="url"
maxlength="2000" maxlength="2000"
bind:value={source.url} placeholder="https://…"
bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)}
/></label /></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} <label class="wide-field"
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if} >Anreißer<textarea
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
>
</div>
</div>
</div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay
content={previewContent}
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
/>
</aside>
</div>
<input
type="hidden"
name="presentationTitleManual"
value={draft.presentationTitleManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationDescriptionManual"
value={draft.presentationDescriptionManual ? '1' : '0'}
/>
<input
type="hidden"
name="presentationImageManual"
value={draft.presentationImageManual ? '1' : '0'}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input
type="hidden"
name="presentationMetadataFetchedAtUtc"
value={draft.presentationMetadataFetchedAtUtc ?? ''}
/>
</section>
</form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
<div class="piece-subsection-heading">
<div>
<p class="eyebrow">Private Dateien</p>
<h3>Material</h3>
</div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} />
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
</form>
</section> </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}
id="preview-load-form" id="preview-load-form"
@@ -455,62 +612,4 @@
> >
<input type="hidden" name="revision" value={revision} /> <input type="hidden" name="revision" value={revision} />
</form> </form>
<section class="editor-section media-editor">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} />
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
</form>
</section>
<section class="editor-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="secondary-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
>
</form>
</section>
</div> </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">
+96 -4
View File
@@ -30,6 +30,16 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme); await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
} }
async function openEditorialField(page: Page, heading: string) {
if (heading.includes('Recherche')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
}
}
async function login(page: Page, email = adminEmail, password = adminPassword) { async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('E-Mail').fill(email); await page.getByLabel('E-Mail').fill(email);
@@ -1014,16 +1024,26 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page).toHaveURL(/\/fundus$/); await 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.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, '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 page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
await openEditorialField(page, 'Private Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
await openEditorialField(page, 'Präsentationstext');
await page 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');
@@ -1032,11 +1052,14 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await 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 expect(page.locator('.markdown-preview script')).toHaveCount(0); await openEditorialField(page, 'Präsentationstext');
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau'); await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).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.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, '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**.'
); );
@@ -1067,6 +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.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.');
@@ -1075,6 +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.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.'
); );
@@ -1099,9 +1124,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'Kopie von E2E Fundstück, extern aktualisiert' 'Kopie von E2E Fundstück, extern aktualisiert'
); );
await page.context().setOffline(true);
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000
});
await page.context().setOffline(false);
await page.getByRole('button', { name: 'Jetzt speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 }); await 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: 'Recherche & Quellen' }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
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'),
@@ -1109,23 +1145,77 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
}); });
}); });
test('a quickly captured video can be written and previewed before classification', async ({
page
}) => {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>YouTube Test</title>'
})
);
await page.goto('/eingang');
await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Private Recherche');
await page
.getByLabel('Private Recherche')
.fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByLabel('Moderationsstichpunkte')
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Video').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
);
await preview.close();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByLabel('Video')).toBeVisible();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({ test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
page page
}, testInfo) => { }, testInfo) => {
await page.goto('/fundus/piece-console'); await page.goto('/fundus/piece-console');
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', 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();
@@ -1146,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')
); );