From 25da870601eaf16ab5e683451f6c2f2591494102 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Thu, 22 Oct 2026 13:51:30 +0200 Subject: [PATCH] Release 0.5.2 --- docs/ACCEPTANCE.md | 26 + docs/ARCHITECTURE.md | 12 + docs/STATUS.md | 10 + package-lock.json | 4 +- package.json | 2 +- src/app.css | 586 ++++++++++++- .../components/EditorialMarkdownField.svelte | 60 ++ src/routes/eingang/+page.server.ts | 5 +- src/routes/fundus/[id]/+page.svelte | 811 +++++++++++------- tests/ui.e2e.ts | 90 ++ 10 files changed, 1308 insertions(+), 298 deletions(-) create mode 100644 src/lib/components/EditorialMarkdownField.svelte diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 893dc47..c870f45 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 637f23c..e240b0b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index 33bd165..62d3681 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 8b5a798..749232c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 48eb5d7..61bcac6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/src/app.css b/src/app.css index b4693e6..5a238f4 100644 --- a/src/app.css +++ b/src/app.css @@ -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, diff --git a/src/lib/components/EditorialMarkdownField.svelte b/src/lib/components/EditorialMarkdownField.svelte new file mode 100644 index 0000000..df600c2 --- /dev/null +++ b/src/lib/components/EditorialMarkdownField.svelte @@ -0,0 +1,60 @@ + + +
+
+
+

{label}

+ {#if hint}

{hint}

{/if} +
+ +
+ + {#if editing} + + {:else} + + {#if value.trim()} + + {:else} + + {/if} + {/if} +
diff --git a/src/routes/eingang/+page.server.ts b/src/routes/eingang/+page.server.ts index 9ef83e8..da8b440 100644 --- a/src/routes/eingang/+page.server.ts +++ b/src/routes/eingang/+page.server.ts @@ -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; diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index 7cca468..597bb59 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -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(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(`[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,22 +176,9 @@ {draft.title} | Fundus | Sendewerk + -
-
-
- ← Fundus -

{draft.title}

-
-
- {saveMessage} -
-
+
{#if form?.error}{/if}
-
-
-

Grundlagen

-

Worum geht es?

+
+
+ ← Fundus +

{draft.title}

+
-
- - - - - +
+ {saveMessage}
-
+ -
-
-

Einordnung

-

Themen und Abschnitte

-
-
- Themen -
- {#each data.taxonomy.topics as item (item.id)}{/each} -
-
-
- Vorgeschlagene Abschnittskategorien -
- {#each data.taxonomy.categories as item (item.id)}{/each} -
-
-
+ -
-
-

Redaktion

-

Getrennte Arbeitsbereiche

- Markdown wird unterstützt und vor der Anzeige bereinigt. -
-
- +
+
- - - -
-
- -
-
-
-

Präsentation

-

Darstellung in heinzlive

- Nur die ausdrücklich ausgewählte Quelle wird präsentiert. -
- Präsentation testen -
-
- {#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])} - - {/each} -
- {#if draft.presentationMode === 'text'} - - - - - - {:else} - -
- - - - -
- - - {draft.presentationMetadataFetchedAtUtc - ? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}` - : 'Noch keine Metadaten geladen.'} - -
-
- {/if} - - - - - - - -
- -
-
-
-

Belege

-

Quellen

-
- -
- {#if sources.length}
- {#each sources as source, index (index)}
- (sourceImageUnavailable = true)} /> + {/if} +
+

Fundstück

+ {#if featuredSource} +

{draft.presentationTitle || featuredSource.label || 'Quelle'}

+ {#if draft.presentationDescription}

{draft.presentationDescription}

{/if} + {featuredSource.label || featuredSource.url} + + + Original öffnen ↗ -
{/each} -
{:else}

Noch keine Quellen hinterlegt.

{/if} -
+ {:else} +

Noch keine Quelle

+

Eine Quelle lässt sich ergänzen, ohne den Schreibstand zu verlassen.

+ + {/if} +
+ + +
+
+
+

Arbeitsstand

+

Gedanken und Sendungstext

+
+ Markdown wird beim Speichern sicher formatiert. +
+ + + +
+

Für die Sendung

+

Sendungstext

+

Einleitung, Moderation und Übergang bleiben getrennt gespeichert.

+
+
+ + + +
+
+ + + +
- {saveMessage}
+
-
+

Private Dateien

Bilder

diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index f052963..f66ec03 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -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 **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: 'YouTube Test' + }) + ); + 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'