Release 0.5.2

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

No files matched your search

+26
View File
@@ -189,3 +189,29 @@
Pixeln visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `bash -n start.sh` und `git diff --check`: erfolgreich
## Redaktionelles Piece-Arbeitsblatt
| Kriterium | Nachweis | Stand |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
## Ausgeführte Prüfungen Piece-Arbeitsblatt
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 29 Tests in 6 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
1 von 1 Browsertest erfolgreich
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
- `bash -n start.sh` und `git diff --check`: erfolgreich
+12
View File
@@ -239,6 +239,18 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
+10
View File
@@ -71,6 +71,16 @@ als bereits implementiert verstanden werden.
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
- der eigenständige Piece-Editor ist als redaktionelles Arbeitsblatt aufgebaut:
Titel, Fundstück, private Recherche und die getrennt gespeicherten
Sendungstexte stehen im Schreibfluss; Quellen, Einordnung, Präsentation und
Bilder begleiten ihn als Desktop-Kontextleiste oder mobile Werkzeuge
- Markdown-Felder wechseln einzeln zwischen Lesen und Bearbeiten, sodass mehrere
Textbereiche gleichzeitig offen bleiben; Autosave, Konfliktschutz und
sichtbare Speicherfehler verwenden unverändert die Piece-Revision
- die Schnellaufnahme „Aufnehmen und öffnen“ leitet nach erfolgreicher Anlage
direkt zum neuen Piece weiter, statt den SvelteKit-Redirect als
Validierungsfehler abzufangen
## Umgesetzter Stand Etappe 4
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
"version": "0.5.1",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.5.1",
"version": "0.5.2",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+585 -1
View File
@@ -2544,6 +2544,467 @@ a {
margin-top: 0.75rem;
}
.worksheet-page {
max-width: 94rem;
}
.worksheet-editor {
gap: 1rem;
}
.worksheet-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1.5rem;
}
.worksheet-heading {
min-width: 0;
flex: 1;
}
.worksheet-title {
display: block;
margin-top: 0.3rem;
}
.worksheet-title textarea {
width: 100%;
height: 1.2em;
padding: 0;
resize: none;
overflow: hidden;
border: 0;
outline-offset: 0.3rem;
background: transparent;
color: var(--text);
font: inherit;
font-size: clamp(1.65rem, 3.3vw, 2.8rem);
font-weight: 760;
letter-spacing: -0.045em;
}
.editor-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(19rem, 23rem);
align-items: start;
gap: 1rem;
}
.editorial-worksheet {
display: grid;
min-width: 0;
gap: 0.8rem;
}
.source-feature {
display: grid;
min-height: 8.5rem;
align-items: stretch;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
}
.source-feature.has-image {
grid-template-columns: minmax(9rem, 0.32fr) minmax(0, 1fr);
}
.source-feature img {
width: 100%;
height: 100%;
min-height: 8.5rem;
object-fit: cover;
}
.source-feature > div {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 1rem 1.15rem;
}
.source-feature h2,
.writing-sheet h2,
.context-card h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.source-feature p:not(.eyebrow) {
margin: 0.3rem 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.source-feature small {
max-width: 100%;
margin-top: 0.2rem;
overflow-wrap: anywhere;
color: var(--text-faint);
font-size: 0.66rem;
}
.source-feature a,
.source-feature .text-button {
margin-top: 0.65rem;
color: var(--accent);
font-size: 0.72rem;
font-weight: 750;
}
.writing-sheet {
padding: clamp(1rem, 2vw, 1.6rem);
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
}
.writing-sheet-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.writing-sheet-heading small,
.script-heading > p:last-child {
color: var(--text-faint);
font-size: 0.67rem;
}
.editorial-field {
padding: 1rem 0;
border-bottom: 1px solid var(--line);
}
.editorial-field.private-field {
margin: 0.85rem 0 0;
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--gold) 38%, var(--line));
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
}
.editorial-field > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.editorial-field h3 {
margin: 0;
font-size: 0.9rem;
}
.editorial-field header p {
margin: 0.18rem 0 0;
color: var(--gold-strong);
font-size: 0.63rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.editorial-edit-button {
flex: 0 0 auto;
color: var(--accent);
}
.editorial-input {
display: block;
margin-top: 0.65rem;
}
.editorial-input textarea {
width: 100%;
min-height: 9rem;
padding: 0.8rem;
resize: vertical;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
font: inherit;
font-size: 0.82rem;
line-height: 1.6;
}
.editorial-field > .markdown-preview {
min-height: 0;
margin-top: 0.55rem;
padding: 0;
border: 0;
color: var(--text);
font-size: 0.82rem;
}
.editorial-empty {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.5rem;
padding: 0.6rem 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text-muted);
text-align: left;
}
.editorial-empty:hover {
color: var(--text);
}
.editorial-empty small {
flex: 0 0 auto;
color: var(--accent);
font-size: 0.64rem;
font-weight: 750;
}
.script-heading {
padding: 1.4rem 0 0.3rem;
}
.script-heading > p:last-child {
margin: 0.25rem 0 0;
}
.script-sections .editorial-field:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.editor-context {
position: sticky;
top: 1rem;
display: grid;
max-height: calc(100vh - 2rem);
gap: 0.7rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.context-card {
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--panel) 96%, transparent);
}
.context-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.7rem;
}
.context-close,
.mobile-editor-tools {
display: none;
}
.context-empty,
.context-summary-copy,
.asset-summary p,
.audience-preview p {
margin: 0.4rem 0;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.5;
}
.context-wide-action {
width: 100%;
margin-top: 0.65rem;
}
.context-card .source-list {
gap: 0.7rem;
}
.context-card .source-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.65fr);
gap: 0.4rem;
padding: 0 0 0.7rem;
}
.context-card .source-row label:nth-child(2) {
grid-column: 1 / -1;
grid-row: 2;
}
.context-card .source-row label:nth-child(3) {
grid-column: 2;
grid-row: 1;
}
.context-card .source-row > button {
grid-column: 1 / -1;
}
.context-card .source-row label,
.context-fields > label,
.presentation-settings > label {
display: grid;
gap: 0.22rem;
color: var(--text-muted);
font-size: 0.66rem;
font-weight: 700;
}
.context-card input:not([type='checkbox'], [type='radio']),
.context-card select,
.context-card textarea {
width: 100%;
min-width: 0;
padding: 0.5rem 0.58rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
}
.classification-summary,
.context-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.classification-summary > *,
.context-tags span {
padding: 0.3rem 0.48rem;
border-radius: 2rem;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.64rem;
}
.classification-summary strong {
background: var(--accent-soft);
color: var(--accent);
}
.context-edit-toggle {
margin-top: 0.65rem;
color: var(--accent);
}
.context-fields,
.presentation-controls {
display: grid;
gap: 0.7rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
.context-fields[hidden],
.presentation-controls[hidden] {
display: none;
}
.context-fields fieldset {
margin: 0;
padding: 0;
border: 0;
}
.context-fields legend {
margin-bottom: 0.35rem;
color: var(--text-muted);
font-size: 0.66rem;
font-weight: 700;
}
.context-fields .choice-grid label {
padding: 0.3rem 0.45rem;
font-size: 0.64rem;
}
.audience-preview {
padding: 0.7rem;
border-radius: var(--radius-small);
background: var(--panel-soft);
}
.audience-preview > span {
color: var(--accent);
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.audience-preview > strong {
display: block;
margin-top: 0.3rem;
font-size: 0.82rem;
}
.audience-preview .markdown-preview {
min-height: 0;
margin-top: 0.4rem;
padding: 0;
border: 0;
font-size: 0.7rem;
}
.presentation-card-actions {
display: grid;
gap: 0.45rem;
margin-top: 0.65rem;
}
.presentation-card-actions .text-button {
justify-self: start;
color: var(--accent);
}
.presentation-card-actions .secondary-button {
width: 100%;
text-align: center;
}
.presentation-controls .editorial-field {
padding: 0.2rem 0 0;
border: 0;
}
.presentation-controls .presentation-settings {
grid-template-columns: 1fr;
margin-top: 0;
}
.presentation-controls .presentation-preview-actions {
align-items: stretch;
flex-direction: column;
}
.asset-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.supporting-editor-section {
max-width: calc(100% - 24rem);
}
.shows-page {
max-width: 78rem;
}
@@ -3272,6 +3733,12 @@ a {
.source-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-workspace {
grid-template-columns: minmax(0, 1fr) minmax(17rem, 20rem);
}
.supporting-editor-section {
max-width: calc(100% - 21rem);
}
.show-create-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -3413,12 +3880,129 @@ a {
align-items: flex-start;
flex-direction: column;
}
.worksheet-header {
align-items: flex-start;
flex-direction: column;
gap: 0.6rem;
}
.worksheet-title textarea {
height: 2.3em;
font-size: 1.65rem;
line-height: 1.12;
}
.mobile-editor-tools {
position: sticky;
top: calc(var(--mobile-header) + 0.45rem);
z-index: 12;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.3rem;
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
}
.mobile-editor-tools button,
.mobile-editor-tools a {
min-width: 0;
padding: 0.55rem 0.3rem;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--text-muted);
font-size: 0.65rem;
font-weight: 750;
text-align: center;
}
.mobile-editor-tools button[aria-expanded='true'] {
background: var(--accent-soft);
color: var(--accent);
}
.editor-workspace {
display: block;
}
.source-feature.has-image {
grid-template-columns: 6.5rem minmax(0, 1fr);
}
.source-feature,
.source-feature img {
min-height: 7.5rem;
}
.source-feature > div {
padding: 0.85rem;
}
.writing-sheet {
margin-top: 0.7rem;
padding: 0.9rem;
border-radius: var(--radius-medium);
}
.writing-sheet-heading {
align-items: flex-start;
flex-direction: column;
gap: 0.35rem;
}
.editorial-field.private-field {
padding: 0.75rem;
}
.editorial-empty {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
.editor-context {
position: static;
max-height: none;
overflow: visible;
}
.editor-context.context-open::before {
position: fixed;
inset: 0;
z-index: 38;
background: color-mix(in srgb, var(--text) 28%, transparent);
content: '';
}
.editor-context .context-card {
display: none;
}
.editor-context .context-card.mobile-active {
position: fixed;
inset: calc(var(--mobile-header) + 0.5rem) 0.5rem calc(var(--mobile-nav) + 0.5rem);
z-index: 40;
display: block;
overflow-y: auto;
padding: 1rem;
border-radius: var(--radius-large);
background: var(--panel-strong);
box-shadow: var(--shadow);
overscroll-behavior: contain;
}
.context-close {
display: inline-grid;
width: 2rem;
height: 2rem;
place-items: center;
padding: 0;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--panel-soft);
color: var(--text);
font-size: 1.2rem;
}
.supporting-editor-section {
max-width: none;
scroll-margin-top: calc(var(--mobile-header) + 4.5rem);
}
.worksheet-editor:has(.editor-context.context-open) .editor-savebar {
visibility: hidden;
}
.editor-section {
padding: 0.85rem;
border-radius: var(--radius-medium);
}
.editor-savebar {
bottom: calc(var(--mobile-nav) + 0.6rem);
position: static;
justify-content: space-between;
}
.split-section-title,
@@ -0,0 +1,60 @@
<script lang="ts">
import MarkdownPreview from './MarkdownPreview.svelte';
let {
name,
label,
inputLabel = label,
hint = '',
emptyText,
value = $bindable(),
html,
privateField = false,
rows = 7
}: {
name: string;
label: string;
inputLabel?: string;
hint?: string;
emptyText: string;
value: string;
html: string;
privateField?: boolean;
rows?: number;
} = $props();
let editing = $state(false);
</script>
<section class:private-field={privateField} class:empty={!value.trim()} class="editorial-field">
<header>
<div>
<h3>{label}</h3>
{#if hint}<p>{hint}</p>{/if}
</div>
<button
class="text-button editorial-edit-button"
type="button"
aria-expanded={editing}
onclick={() => (editing = !editing)}
>
{editing ? 'Lesen' : value.trim() ? 'Bearbeiten' : 'Schreiben'}
</button>
</header>
{#if editing}
<label class="editorial-input">
<span class="sr-only">{inputLabel}</span>
<textarea aria-label={inputLabel} {name} {rows} maxlength="50000" bind:value></textarea>
</label>
{:else}
<input type="hidden" {name} {value} />
{#if value.trim()}
<MarkdownPreview {html} />
{:else}
<button class="editorial-empty" type="button" onclick={() => (editing = true)}>
<span>{emptyText}</span><small>Zum Schreiben öffnen</small>
</button>
{/if}
{/if}
</section>
+3 -2
View File
@@ -12,9 +12,9 @@ export const actions = {
capture: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
@@ -26,5 +26,6 @@ export const actions = {
: 'Ein Titel ist erforderlich.'
});
}
redirect(303, `/fundus/${id}`);
}
} satisfies Actions;
+376 -149
View File
@@ -2,10 +2,13 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import { onDestroy, tick, untrack } from 'svelte';
import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import type { PageProps } from './$types';
type ContextPanel = 'sources' | 'classification' | 'presentation';
let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece })));
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
@@ -14,6 +17,10 @@
let rendered = $state(untrack(() => ({ ...data.rendered })));
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let classificationEditing = $state(false);
let presentationEditing = $state(false);
let mobileContext = $state<ContextPanel | null>(null);
let sourceImageUnavailable = $state(false);
let editorForm: HTMLFormElement;
let previewForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
@@ -26,8 +33,27 @@
saveState === 'error' ||
saveState === 'conflict'
);
let featuredSource = $derived(
draft.presentationSourcePosition === null
? (sources[0] ?? null)
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
);
let sourcePreviewImage = $derived(
draft.presentationImageUrl
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: data.piece.media[0]
? `/media/${data.piece.media[0].id}`
: ''
);
let pendingHref: string | null = null;
let allowNavigation = false;
let contextTrigger: HTMLElement | null = null;
const statusLabels = {
inbox: 'Eingang',
preparing: 'In Vorbereitung',
ready: 'Sendefertig'
};
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
@@ -87,11 +113,29 @@
draft.presentationMetadataFetchedAtUtc = null;
draft.presentationProvider = '';
draft.presentationProviderId = '';
sourceImageUnavailable = false;
if (!draft.presentationTitleManual) draft.presentationTitle = '';
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
}
async function openContext(event: MouseEvent, panel: ContextPanel) {
contextTrigger = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
mobileContext = panel;
await tick();
document.querySelector<HTMLElement>(`[data-context-close="${panel}"]`)?.focus();
}
function closeContext() {
mobileContext = null;
contextTrigger?.focus();
contextTrigger = null;
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && mobileContext) closeContext();
}
const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation;
saving = true;
@@ -132,12 +176,35 @@
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<svelte:window onkeydown={handleEscape} />
<div class="page-wrap editor-page">
<header class="editor-header">
<div>
<div class="page-wrap editor-page worksheet-page">
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
bind:this={editorForm}
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor worksheet-editor"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<header class="worksheet-header">
<div class="worksheet-heading">
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
<h1 class="sr-only">{draft.title}</h1>
<label class="worksheet-title">
<span class="sr-only">Titel</span>
<textarea
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}></textarea>
</label>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
@@ -148,41 +215,227 @@
<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}
<nav class="mobile-editor-tools" aria-label="Piece-Werkzeuge">
<button
type="button"
aria-expanded={mobileContext === 'sources'}
onclick={(event) => openContext(event, 'sources')}>Quellen</button
>
<input type="hidden" name="revision" value={revision} />
<section class="editor-section editor-basics">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h2>Worum geht es?</h2>
<button
type="button"
aria-expanded={mobileContext === 'classification'}
onclick={(event) => openContext(event, 'classification')}>Einordnung</button
>
<button
type="button"
aria-expanded={mobileContext === 'presentation'}
onclick={(event) => openContext(event, 'presentation')}>Präsentation</button
>
<a href="#piece-media">Bilder</a>
</nav>
<div class="editor-workspace">
<main class="editorial-worksheet" aria-label="Redaktionelles Arbeitsblatt">
<section
class:has-image={sourcePreviewImage && !sourceImageUnavailable}
class="source-feature"
>
{#if sourcePreviewImage && !sourceImageUnavailable}
<img src={sourcePreviewImage} alt="" onerror={() => (sourceImageUnavailable = true)} />
{/if}
<div>
<p class="eyebrow">Fundstück</p>
{#if featuredSource}
<h2>{draft.presentationTitle || featuredSource.label || 'Quelle'}</h2>
{#if draft.presentationDescription}<p>{draft.presentationDescription}</p>{/if}
<small>{featuredSource.label || featuredSource.url}</small>
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={featuredSource.url} target="_blank" rel="noopener noreferrer"
>Original öffnen ↗</a
>
{:else}
<h2>Noch keine Quelle</h2>
<p>Eine Quelle lässt sich ergänzen, ohne den Schreibstand zu verlassen.</p>
<button
class="text-button"
type="button"
onclick={(event) => openContext(event, 'sources')}>Quelle ergänzen</button
>
{/if}
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
</section>
<section class="writing-sheet">
<header class="writing-sheet-heading">
<div>
<p class="eyebrow">Arbeitsstand</p>
<h2>Gedanken und Sendungstext</h2>
</div>
<small>Markdown wird beim Speichern sicher formatiert.</small>
</header>
<EditorialMarkdownField
name="researchText"
label="Eigene Gedanken & Recherche"
inputLabel="Private Recherche"
hint="Nur intern"
emptyText="Beobachtungen, Links und offene Fragen festhalten"
bind:value={draft.researchText}
html={rendered.research}
privateField
rows={9}
/>
<div class="script-heading">
<p class="eyebrow">Für die Sendung</p>
<h2>Sendungstext</h2>
<p>Einleitung, Moderation und Übergang bleiben getrennt gespeichert.</p>
</div>
<div class="script-sections">
<EditorialMarkdownField
name="intro"
label="Einleitung"
emptyText="Wie beginnt dieses Thema in der Sendung?"
bind:value={draft.intro}
html={rendered.intro}
/>
<EditorialMarkdownField
name="moderationNotes"
label="Moderationsstichpunkte"
emptyText="Die wichtigsten Punkte für die Moderation notieren"
bind:value={draft.moderationNotes}
html={rendered.moderation}
/>
<EditorialMarkdownField
name="transitionText"
label="Überleitung"
emptyText="Den Übergang zum nächsten Thema vorbereiten"
bind:value={draft.transitionText}
html={rendered.transition}
/>
</div>
</section>
</main>
<aside
class:context-open={mobileContext}
class="editor-context"
aria-label="Werkzeuge zum Piece"
>
<section
class:mobile-active={mobileContext === 'sources'}
class="context-card source-editor"
aria-labelledby="source-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Fundstück</p>
<h2 id="source-context-title">Quellen</h2>
</div>
<button
class="context-close"
data-context-close="sources"
type="button"
aria-label="Quellen schließen"
onclick={closeContext}>×</button
>
</header>
{#if sources.length}
<div class="source-list">
{#each sources as source, index (index)}
<div class="source-row">
<label
>Name<input
name="sourceLabel"
maxlength="200"
bind:value={source.label}
/></label
>
<label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
>
<label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
>
<button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>
{/each}
</div>
{:else}
<p class="context-empty">Noch keine Quelle hinterlegt.</p>
{/if}
<button class="secondary-button context-wide-action" type="button" onclick={addSource}
>Quelle hinzufügen</button
>
</section>
<section
class:mobile-active={mobileContext === 'classification'}
class="context-card classification-editor"
aria-labelledby="classification-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Überblick</p>
<h2 id="classification-context-title">Einordnung</h2>
</div>
<button
class="context-close"
data-context-close="classification"
type="button"
aria-label="Einordnung schließen"
onclick={closeContext}>×</button
>
</header>
<div class="classification-summary">
<strong>{statusLabels[draft.status as keyof typeof statusLabels]}</strong>
<span>{durationMinutes || 0} Min.</span>
<span
>{data.taxonomy.formats.find((item) => item.id === draft.formatId)?.name ??
'Ohne Format'}</span
>
</div>
{#if draft.summary}<p class="context-summary-copy">{draft.summary}</p>{/if}
<div class="context-tags">
{#each data.taxonomy.topics.filter( (item) => draft.topicIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
{#each data.taxonomy.categories.filter( (item) => draft.categoryIds.includes(item.id) ) as item (item.id)}<span
>{item.name}</span
>{/each}
</div>
<button
class="text-button context-edit-toggle"
type="button"
aria-expanded={classificationEditing}
onclick={() => (classificationEditing = !classificationEditing)}
>{classificationEditing ? 'Einordnung schließen' : 'Einordnung bearbeiten'}</button
>
<div class="context-fields" hidden={!classificationEditing}>
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
><option value="inbox">Eingang</option><option value="preparing"
>In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Minuten<input
name="durationMinutes"
@@ -193,21 +446,21 @@
bind:value={durationMinutes}
/></label
>
<label class="wide-field"
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Kurzbeschreibung<textarea
name="summary"
rows="2"
rows="3"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
<section class="editor-section taxonomy-editor">
<div class="section-title">
<p class="eyebrow">Einordnung</p>
<h2>Themen und Abschnitte</h2>
</div>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
@@ -234,57 +487,50 @@
>{/each}
</div>
</fieldset>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h2>Getrennte Arbeitsbereiche</h2>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
</div>
</section>
<section class="editor-section presentation-editor">
<div class="section-title split-section-title">
<section
class:mobile-active={mobileContext === 'presentation'}
class="context-card presentation-editor"
aria-labelledby="presentation-context-title"
>
<header class="context-card-heading">
<div>
<p class="eyebrow">Präsentation</p>
<h2>Darstellung in heinzlive</h2>
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
<p class="eyebrow">Publikum</p>
<h2 id="presentation-context-title">Präsentation</h2>
</div>
<button
class="context-close"
data-context-close="presentation"
type="button"
aria-label="Präsentation schließen"
onclick={closeContext}>×</button
>
</header>
<div class="audience-preview">
<span
>{draft.presentationMode === 'text'
? 'Text'
: draft.presentationMode === 'link'
? 'Linkvorschau'
: 'Video'}</span
>
<strong>{draft.presentationTitle || draft.title}</strong>
{#if draft.presentationMode === 'text' && rendered.presentation}<MarkdownPreview
html={rendered.presentation}
/>{:else if draft.presentationDescription}<p>
{draft.presentationDescription}
</p>{:else}<p>Noch keine Vorschauangaben.</p>{/if}
</div>
<div class="presentation-card-actions">
<button
class="text-button"
type="button"
aria-expanded={presentationEditing}
onclick={() => (presentationEditing = !presentationEditing)}
>{presentationEditing ? 'Einstellungen schließen' : 'Präsentation bearbeiten'}</button
>
<a
class="secondary-button"
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
@@ -292,17 +538,16 @@
rel="noopener">Präsentation testen</a
>
</div>
<div class="presentation-controls" hidden={!presentationEditing}>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
{#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}
>{/each}
</div>
{#if draft.presentationMode === 'text'}
<input
@@ -311,30 +556,33 @@
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input type="hidden" name="presentationDescription" value={draft.presentationDescription} />
<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
<EditorialMarkdownField
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
label="Präsentationstext"
emptyText="Text für die Publikumsausgabe schreiben"
bind:value={draft.presentationText}
html={rendered.presentation}
rows={8}
/>
{:else}
<input type="hidden" name="presentationText" value={draft.presentationText} />
<div class="presentation-settings">
<label class="wide-field"
<label
>Quelle<select
name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged}
>
<option value={null}>Quelle auswählen</option>
{#each sources as source, index (index)}
{#if source.url}<option value={index}>{source.label || source.url}</option>{/if}
{/each}
</select></label
><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
@@ -353,7 +601,7 @@
oninput={() => (draft.presentationImageManual = true)}
/></label
>
<label class="wide-field"
<label
>Anreißer<textarea
name="presentationDescription"
rows="3"
@@ -361,22 +609,22 @@
bind:value={draft.presentationDescription}
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
>
<div class="presentation-preview-actions wide-field">
<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
><small
>{draft.presentationMetadataFetchedAtUtc
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch keine Metadaten geladen.'}
</small>
: 'Noch keine Metadaten geladen.'}</small
>
</div>
</div>
{/if}
</div>
<input
type="hidden"
name="presentationTitleManual"
@@ -402,50 +650,29 @@
/>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<section class="context-card asset-summary">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
<p class="eyebrow">Material</p>
<h2>Bilder</h2>
<p>
{data.piece.media.length
? `${data.piece.media.length} gespeichert`
: 'Noch keine Bilder'}
</p>
</div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
<a class="text-link" href="#piece-media">Bilder verwalten ↓</a>
</section>
</aside>
</div>
<div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span>
<button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
>Jetzt speichern</button
>
</div>
</form>
<form
bind:this={previewForm}
id="preview-load-form"
@@ -456,7 +683,7 @@
<input type="hidden" name="revision" value={revision} />
</form>
<section class="editor-section media-editor">
<section id="piece-media" class="editor-section media-editor supporting-editor-section">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
+90
View File
@@ -30,6 +30,14 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function openEditorialField(page: Page, heading: string) {
const field = page.locator('.editorial-field').filter({
has: page.getByRole('heading', { name: heading, exact: true })
});
await field.locator('.editorial-edit-button').click();
return field;
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
@@ -1014,13 +1022,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte');
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await openEditorialField(page, 'Präsentationstext');
await page
.getByLabel('Präsentationstext')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
@@ -1032,11 +1047,18 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await page
.locator('.editorial-field')
.filter({ has: page.getByRole('heading', { name: 'Präsentationstext', exact: true }) })
.getByRole('button', { name: 'Lesen' })
.click();
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Eigene Gedanken & Recherche');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
@@ -1067,6 +1089,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
@@ -1075,6 +1098,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
});
await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
@@ -1099,9 +1123,21 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'Kopie von E2E Fundstück, extern aktualisiert'
);
await page.context().setOffline(true);
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung');
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000
});
await page.context().setOffline(false);
await page.getByRole('button', { name: 'Jetzt speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await page.getByRole('button', { name: 'Quellen', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Quellen schließen' }).click();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
@@ -1109,16 +1145,70 @@ 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('Titel oder Gedanke').fill('Schnelles Video-Fundstück');
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
await page.getByRole('button', { name: 'Aufnehmen und öffnen' }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
await expect(page.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute(
'href',
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
await openEditorialField(page, 'Eigene Gedanken & 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 expect(page.getByLabel('Private Recherche')).toBeVisible();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByLabel('Video').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Präsentation testen' }).click();
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: 'Präsentation schließen' }).click();
await expect(page.getByLabel('Private Recherche')).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
page
}, testInfo) => {
await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await page.getByLabel('Linkvorschau').check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
'Manuell korrigierte Linkvorschau'