diff --git a/docs/STATUS.md b/docs/STATUS.md index 62d3681..90280e2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -71,16 +71,28 @@ als bereits implementiert verstanden werden. Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung - standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter -- 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 +- 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 diff --git a/package-lock.json b/package-lock.json index 749232c..d36d868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.5.2", + "version": "0.5.3", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index 61bcac6..80fd4db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.5.2", + "version": "0.5.3", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/src/app.css b/src/app.css index 5a238f4..aa51b3d 100644 --- a/src/app.css +++ b/src/app.css @@ -3231,6 +3231,430 @@ a { color: var(--accent); } +.piece-editor-route { + min-height: calc(100dvh - 2rem); +} + +.piece-editor-dialog { + display: none; + width: min(76rem, calc(100vw - 2rem)); + height: min(54rem, calc(100dvh - 2rem)); + max-width: none; + max-height: none; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr) auto; + padding: 0; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: var(--radius-large); + background: var(--panel-strong); + box-shadow: var(--shadow-large); + color: var(--text); +} + +.piece-editor-dialog[open] { + display: grid; +} + +.piece-editor-dialog::backdrop { + background: color-mix(in srgb, #07110e 62%, transparent); + backdrop-filter: blur(6px); +} + +.piece-dialog-header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1rem 0.75rem; + border-bottom: 1px solid var(--line); + background: var(--panel-strong); +} + +.piece-dialog-heading { + min-width: 0; + flex: 1; +} + +.piece-dialog-heading > .eyebrow { + margin-bottom: 0.18rem; +} + +.piece-dialog-heading > small { + display: block; + margin-top: 0.25rem; + color: var(--text-faint); + font-size: 0.65rem; +} + +.piece-dialog-title { + display: block; +} + +.piece-dialog-title textarea { + width: 100%; + min-width: 0; + height: 1.2em; + padding: 0; + resize: none; + overflow: hidden; + border: 0; + outline-offset: 0.2rem; + background: transparent; + color: var(--text); + font: inherit; + font-size: clamp(1.25rem, 2.5vw, 1.85rem); + font-weight: 760; + letter-spacing: -0.035em; +} + +.piece-dialog-tabs { + display: flex; + min-width: 0; + gap: 0.25rem; + padding: 0.45rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--panel-strong); +} + +.piece-dialog-tabs button { + padding: 0.48rem 0.72rem; + border: 0; + border-radius: var(--radius-small); + background: transparent; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 740; + cursor: pointer; +} + +.piece-dialog-tabs button.active { + background: var(--accent-soft); + color: var(--accent); +} + +.piece-dialog-workspace { + min-width: 0; + min-height: 0; + padding: clamp(0.85rem, 2vw, 1.4rem); + overflow: auto; + overscroll-behavior: contain; +} + +.piece-dialog-form { + display: block; +} + +.piece-editor-panel { + width: min(100%, 66rem); + margin: 0 auto; +} + +.piece-editor-panel[hidden] { + display: none; +} + +.piece-panel-heading, +.piece-subsection-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; +} + +.piece-panel-heading h2, +.piece-panel-heading h3, +.piece-subsection-heading h3 { + margin: 0.12rem 0 0; + font-size: 1.05rem; +} + +.piece-panel-heading small, +.piece-subsection-heading small { + color: var(--text-faint); + font-size: 0.65rem; +} + +.piece-writing-stack { + display: grid; + gap: 0.8rem; +} + +.markdown-editor-field { + display: block; + padding: 0.8rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel); +} + +.markdown-editor-field.private-field { + border-color: color-mix(in srgb, var(--gold) 38%, var(--line)); + background: color-mix(in srgb, var(--gold) 5%, var(--panel)); +} + +.markdown-editor-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + margin-bottom: 0.45rem; +} + +.markdown-editor-heading > span:first-child { + font-size: 0.76rem; + font-weight: 760; +} + +.markdown-editor-heading small { + margin-left: 0.4rem; + color: var(--gold-strong); + font-size: 0.58rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.markdown-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; +} + +.markdown-toolbar button { + min-width: 1.8rem; + padding: 0.22rem 0.4rem; + border: 1px solid var(--line); + border-radius: 0.45rem; + background: var(--panel-soft); + color: var(--text-muted); + font-size: 0.65rem; + cursor: pointer; +} + +.markdown-editor-field textarea { + display: block; + width: 100%; + min-height: 7.5rem; + padding: 0.65rem; + 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.55; +} + +.piece-meta-details, +.piece-source-editor, +.piece-media-panel { + margin-top: 0.9rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel); +} + +.piece-meta-details summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + cursor: pointer; + font-size: 0.75rem; + font-weight: 740; +} + +.piece-meta-details summary small { + color: var(--text-faint); + font-size: 0.64rem; + font-weight: 600; +} + +.piece-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; + margin-top: 0.8rem; +} + +.piece-meta-grid > label, +.piece-meta-grid fieldset, +.piece-source-editor label, +.piece-presentation-controls .presentation-settings > label { + display: grid; + gap: 0.25rem; + margin: 0; + color: var(--text-muted); + font-size: 0.66rem; + font-weight: 700; +} + +.piece-meta-grid input:not([type='checkbox']), +.piece-meta-grid select, +.piece-meta-grid textarea, +.piece-source-editor input, +.piece-presentation-controls input:not([type='radio']), +.piece-presentation-controls select, +.piece-presentation-controls textarea, +.piece-dialog-secondary-actions select { + width: 100%; + min-width: 0; + padding: 0.55rem 0.62rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + background: var(--panel-strong); + color: var(--text); +} + +.piece-meta-grid .wide-field, +.piece-meta-grid fieldset { + grid-column: 1 / -1; +} + +.piece-meta-grid fieldset { + padding: 0; + border: 0; +} + +.piece-source-editor .source-row { + display: grid; + grid-template-columns: minmax(8rem, 0.8fr) minmax(14rem, 1.5fr) minmax(8rem, 0.6fr) auto; + align-items: end; + gap: 0.5rem; + padding: 0.6rem 0; + border-top: 1px solid var(--line); +} + +.piece-source-summary { + margin-top: 0.9rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel); +} + +.piece-source-summary strong, +.piece-source-summary small { + display: block; + margin-top: 0.3rem; + overflow-wrap: anywhere; +} + +.piece-source-summary small, +.piece-source-summary p:not(.eyebrow) { + color: var(--text-muted); + font-size: 0.7rem; +} + +.piece-presentation-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.8fr); + align-items: start; + gap: 1rem; +} + +.piece-presentation-controls, +.piece-presentation-controls .presentation-settings { + display: grid; + gap: 0.7rem; +} + +.piece-presentation-preview { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--presentation-bg, #0b1220); + color: #fff7e8; +} + +.piece-presentation-preview > .output-content { + min-height: 20rem; + padding: 1rem; +} + +.piece-presentation-preview .output-content h1 { + max-width: none; + margin: 0.35rem 0 0.75rem; + font-size: clamp(1.55rem, 3vw, 2.4rem); + line-height: 1; +} + +.piece-presentation-preview .output-copy, +.piece-presentation-preview .output-link-copy > p:not(.output-kicker) { + font-size: 0.9rem; +} + +.piece-presentation-preview .output-link-card { + grid-template-columns: 1fr; + gap: 0.8rem; +} + +.piece-presentation-preview .output-link-image { + min-height: 10rem; +} + +.piece-presentation-preview .output-sources { + margin-top: 1rem; +} + +.piece-presentation-preview > p, +.piece-presentation-preview > h4, +.piece-presentation-preview > img { + margin: 0.8rem; +} + +.piece-presentation-preview > img { + max-width: calc(100% - 1.6rem); + max-height: 12rem; + object-fit: contain; +} + +.preview-markdown-source { + white-space: pre-wrap; +} + +.piece-dialog-footer { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + padding: 0.65rem 1rem; + border-top: 1px solid var(--line); + background: var(--panel-strong); +} + +.piece-dialog-secondary-actions, +.piece-dialog-primary-action { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.piece-dialog-secondary-actions form { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.piece-dialog-primary-action span { + color: var(--text-muted); + font-size: 0.66rem; +} + +.route-editor-error { + position: fixed; + z-index: 100; + top: 1rem; + left: 50%; + transform: translateX(-50%); +} + .usage-editor-dialog { width: min(60rem, calc(100vw - 2rem)); max-width: none; @@ -4041,6 +4465,93 @@ a { .usage-dialog-actions button { width: 100%; } + .piece-editor-dialog { + width: 100vw; + height: 100dvh; + border: 0; + border-radius: 0; + } + .piece-dialog-header { + padding: 0.75rem 0.8rem 0.6rem; + } + .piece-dialog-heading > small { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .piece-dialog-title textarea { + height: 2.35em; + font-size: 1.3rem; + line-height: 1.12; + } + .piece-dialog-tabs { + overflow-x: auto; + padding: 0.4rem 0.6rem; + } + .piece-dialog-tabs button { + flex: 1 0 auto; + padding-inline: 0.55rem; + } + .piece-dialog-workspace { + padding: 0.7rem; + } + .piece-panel-heading, + .piece-subsection-heading, + .markdown-editor-heading { + align-items: flex-start; + flex-direction: column; + gap: 0.4rem; + } + .markdown-toolbar { + width: 100%; + } + .markdown-toolbar button { + min-height: 2rem; + } + .piece-meta-details summary { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } + .piece-meta-grid, + .piece-source-editor .source-row, + .piece-presentation-layout { + grid-template-columns: 1fr; + } + .piece-source-editor .source-row { + align-items: stretch; + } + .piece-source-editor .source-row > button { + justify-self: start; + } + .piece-presentation-preview > .output-content { + min-height: 16rem; + } + .piece-dialog-footer { + align-items: center; + padding: 0.5rem 0.65rem calc(0.5rem + env(safe-area-inset-bottom)); + } + .piece-dialog-secondary-actions { + display: grid; + min-width: 0; + max-width: calc(100% - 11rem); + grid-auto-columns: max-content; + grid-auto-flow: column; + align-items: center; + overflow-x: auto; + } + .piece-dialog-secondary-actions form { + flex: 0 0 auto; + } + .piece-dialog-secondary-actions .text-button { + white-space: nowrap; + } + .piece-dialog-primary-action { + margin-left: auto; + } + .piece-dialog-primary-action span { + display: none; + } .library-toolbar { align-items: stretch; flex-direction: column; diff --git a/src/lib/components/EditorialMarkdownField.svelte b/src/lib/components/EditorialMarkdownField.svelte deleted file mode 100644 index df600c2..0000000 --- a/src/lib/components/EditorialMarkdownField.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
-
-
-

{label}

- {#if hint}

{hint}

{/if} -
- -
- - {#if editing} - - {:else} - - {#if value.trim()} - - {:else} - - {/if} - {/if} -
diff --git a/src/lib/components/MarkdownEditorField.svelte b/src/lib/components/MarkdownEditorField.svelte new file mode 100644 index 0000000..83aa7a9 --- /dev/null +++ b/src/lib/components/MarkdownEditorField.svelte @@ -0,0 +1,81 @@ + + +
+
+ {label}{#if privateField}Nur intern{/if} + + + + + + +
+ +
diff --git a/src/lib/components/PieceEditorDialog.svelte b/src/lib/components/PieceEditorDialog.svelte new file mode 100644 index 0000000..a18a651 --- /dev/null +++ b/src/lib/components/PieceEditorDialog.svelte @@ -0,0 +1,81 @@ + + + { + event.preventDefault(); + onrequestclose(); + }} + onclick={(event) => event.target === dialog && onrequestclose()} +> +
+
{@render header()}
+ +
+ +
{@render children()}
+ +
diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte index c5a6835..4f005f6 100644 --- a/src/lib/components/UsageEditorDialog.svelte +++ b/src/lib/components/UsageEditorDialog.svelte @@ -1,7 +1,9 @@ - event.target === dialog && dialog.close()} + -
-
-

Sendungsspezifische Kopie

-

{usage.title}

-
-
- -
-
-
- {#if error}{/if} -
- - -
-
-

Grundlagen

-

Titel, Dauer und Notizen

+ {#snippet header()} +

Verwendung in dieser Sendung

+ +

{draft.title}

+ Änderungen gelten nur für diese Einplanung. + {/snippet} + + (dirty = true)} + onchange={() => (dirty = true)} + > + + + + + +
-
-
-

Redaktion

-

Getrennte Arbeitsbereiche

+ + + - - - - -
-
-
- - - -
- Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben. +
-
+
+ + + {#snippet footer()} +
+
- + +
+
+ +
- - - + Ziel - +
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()} > - - - + +
-
-
-
+ +
+ {#if error}{error}{/if} + +
+ {/snippet} + diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts index 9d10e11..9db42fb 100644 --- a/src/lib/server/content.ts +++ b/src/lib/server/content.ts @@ -620,10 +620,11 @@ export function createPiece(template: PieceTemplateId = 'blank'): string { } export function captureInbox(titleValue: string, urlValue: string): string { - const title = cleanText(titleValue, 200, 'title'); - if (!title) throw new Error('INVALID_TITLE'); + let title = cleanText(titleValue, 200, 'title'); const url = cleanText(urlValue, 2_000, 'source'); if (url && !validUrl(url)) throw new Error('INVALID_SOURCES'); + if (!title && !url) throw new Error('INVALID_TITLE'); + if (!title) title = new URL(url).hostname; const id = createPiece('blank'); const piece = getPiece(id)!; savePiece(id, piece.revision, { diff --git a/src/routes/eingang/+page.server.ts b/src/routes/eingang/+page.server.ts index da8b440..10adfb9 100644 --- a/src/routes/eingang/+page.server.ts +++ b/src/routes/eingang/+page.server.ts @@ -1,4 +1,4 @@ -import { fail, redirect } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { requireUser } from '#lib/server/auth.js'; import { captureInbox, listPieces } from '#lib/server/content.js'; @@ -12,9 +12,9 @@ export const actions = { capture: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); - let id: string; try { - id = captureInbox(String(data.get('title') || ''), String(data.get('url') || '')); + const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || '')); + return { captured: true, capturedId: id }; } catch (cause) { const code = cause instanceof Error ? cause.message : ''; return fail(400, { @@ -26,6 +26,5 @@ export const actions = { : 'Ein Titel ist erforderlich.' }); } - redirect(303, `/fundus/${id}`); } } satisfies Actions; diff --git a/src/routes/eingang/+page.svelte b/src/routes/eingang/+page.svelte index 3c2e523..8365b51 100644 --- a/src/routes/eingang/+page.svelte +++ b/src/routes/eingang/+page.svelte @@ -17,11 +17,14 @@

Was soll nicht verloren gehen?

{#if form?.error}{/if} + {#if form?.captured}
+ Im Eingang gespeichert. + Jetzt ausarbeiten +
{/if}
- +
{#if data.pieces.length} diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index 597bb59..f9c9bcf 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -2,25 +2,24 @@ import { enhance, type SubmitFunction } from '$app/forms'; import { beforeNavigate, goto } from '$app/navigation'; import { resolve } from '$app/paths'; - import { onDestroy, tick, untrack } from 'svelte'; - import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte'; - import MarkdownPreview from '#lib/components/MarkdownPreview.svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; + import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte'; + import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte'; + import PresentationDisplay from '#lib/components/PresentationDisplay.svelte'; + import type { PresentationContent } from '#lib/server/presentation.js'; import type { PageProps } from './$types'; - 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)))); let revision = $state(untrack(() => data.piece.revision)); let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source })))); let rendered = $state(untrack(() => ({ ...data.rendered }))); + let activeSection = $state<'script' | 'research' | 'presentation'>('script'); let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle'); let saveMessage = $state('Alle Änderungen gespeichert'); - let classificationEditing = $state(false); - let presentationEditing = $state(false); - let mobileContext = $state(null); let sourceImageUnavailable = $state(false); + let editorDialog: { showModal: () => void; close: () => void }; let editorForm: HTMLFormElement; let previewForm: HTMLFormElement; let saveButton: HTMLButtonElement; @@ -41,13 +40,34 @@ 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 previewContent = $derived({ + mode: draft.presentationMode, + title: draft.title, + html: rendered.presentation, + source: featuredSource + ? { + label: featuredSource.label || featuredSource.url, + url: featuredSource.url, + timecodeSeconds: featuredSource.timecodeSeconds + } + : null, + previewTitle: draft.presentationTitle, + description: draft.presentationDescription, + imageUrl: draft.presentationImageUrl, + siteName: draft.presentationSiteName, + provider: draft.presentationProvider as PresentationContent['provider'], + providerId: draft.presentationProviderId, + embedUrl: + draft.presentationProvider === 'youtube' && draft.presentationProviderId + ? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}` + : draft.presentationProvider === 'tiktok' && draft.presentationProviderId + ? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}` + : '' + }); let pendingHref: string | null = null; let allowNavigation = false; - let contextTrigger: HTMLElement | null = null; const statusLabels = { inbox: 'Eingang', @@ -55,6 +75,7 @@ ready: 'Sendefertig' }; + onMount(() => editorDialog.showModal()); onDestroy(() => clearTimeout(timer)); beforeNavigate((navigation) => { if (allowNavigation) return; @@ -92,6 +113,23 @@ editorForm.requestSubmit(saveButton); } + function requestClose() { + const target = resolve('/fundus'); + if (saveState === 'error' || saveState === 'conflict') { + if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return; + allowNavigation = true; + void goto(target); + return; + } + if (saveState === 'dirty' || saveState === 'saving') { + pendingHref = new URL(target, window.location.href).href; + if (!saving) submitSave(); + return; + } + allowNavigation = true; + void goto(target); + } + function addSource() { sources.push({ label: '', url: '', timecodeSeconds: null }); scheduleSave(); @@ -119,23 +157,6 @@ 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; @@ -176,259 +197,84 @@ {draft.title} | Fundus | Sendewerk - -
- {#if form?.error}{/if} - -
+ {#if form?.error}{/if} + - -
-
- ← Fundus -

{draft.title}

- -
-
- {saveMessage} -
-
+ {#snippet header()} +

Fundus-Original

+ +

{draft.title}

+ Original im Fundus · Änderungen wirken nicht rückwirkend auf Einplanungen. + {/snippet} - + + -
-
-
- {#if sourcePreviewImage && !sourceImageUnavailable} - (sourceImageUnavailable = true)} /> - {/if} + - -
-
-
-

Arbeitsstand

-

Gedanken und Sendungstext

-
- Markdown wird beim Speichern sicher formatiert. -
- - - -
-

Für die Sendung

+

Für die Moderation

Sendungstext

-

Einleitung, Moderation und Übergang bleiben getrennt gespeichert.

-
- - - -
-
-
- -
+
+ + + +
+
+ + Status, Dauer und Einordnung + {statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min. - - {#if sources.length} -
- {#each sources as source, index (index)} -
- - - - -
- {/each} -
- {:else} -

Noch keine Quelle hinterlegt.

- {/if} - - - -
-
-
-

Überblick

-

Einordnung

-
- -
-
- {statusLabels[draft.status as keyof typeof statusLabels]} - {durationMinutes || 0} Min. - {data.taxonomy.formats.find((item) => item.id === draft.formatId)?.name ?? - 'Ohne Format'} -
- {#if draft.summary}

{draft.summary}

{/if} -
- {#each data.taxonomy.topics.filter( (item) => draft.topicIds.includes(item.id) ) as item (item.id)}{item.name}{/each} - {#each data.taxonomy.categories.filter( (item) => draft.categoryIds.includes(item.id) ) as item (item.id)}{item.name}{/each} -
- -
+
- +
+ {draft.presentationMetadataFetchedAtUtc + ? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}` + : 'Noch keine Metadaten geladen.'} - - - -
- {draft.presentationMetadataFetchedAtUtc - ? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}` - : 'Noch keine Metadaten geladen.'} -
- {/if} +
- - - - - - - - + + + + + + + + + + + -
-
-

Material

-

Bilder

-

- {data.piece.media.length - ? `${data.piece.media.length} gespeichert` - : 'Noch keine Bilder'} -

-
- Bilder verwalten ↓ -
- - + -
- {saveMessage} - -
- + {#snippet footer()} +
+
+ +
+
+ +
+
+
+ {saveMessage} + +
+ {/snippet} +
- -
-
-

Private Dateien

-

Bilder

- JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar. -
- {#if data.piece.media.length}
- {#each data.piece.media as media (media.id)}
- {media.altText} -
- {media.altText || media.originalName} -
- -
-
-
{/each} -
{/if} -
- -
-
- -
-
- -
-
- -
-
diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index f66ec03..4b7b87a 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -31,11 +31,13 @@ async function setTheme(page: Page, theme: 'light' | 'dark') { } 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; + 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) { @@ -1022,23 +1024,26 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await expect(page).toHaveURL(pieceUrl); - await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); + await page.getByText('Status, Dauer und Einordnung').click(); await page .getByLabel('Kurzbeschreibung') .fill('Persistiert nach Neuladen und ist gezielt suchbar.'); await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Gaming').check(); - await openEditorialField(page, 'Eigene Gedanken & Recherche'); + await openEditorialField(page, 'Private Recherche'); await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await openEditorialField(page, 'Moderationsstichpunkte'); await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.'); - await expect(page.getByLabel('Private Recherche')).toBeVisible(); - await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); + await openEditorialField(page, 'Private Recherche'); + await expect(page.getByLabel('Private Recherche')).toHaveValue( + 'Nur intern: **Hintergrund prüfen**.' + ); await openEditorialField(page, 'Präsentationstext'); await page - .getByLabel('Präsentationstext') + .getByLabel('Öffentlicher Text') .fill('## Sichere Vorschau\n\n **sichtbar**'); + await openEditorialField(page, 'Private Recherche'); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); const source = page.locator('.source-row').last(); await source.getByLabel('Name').fill('Originalbeitrag'); @@ -1047,18 +1052,14 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await 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 openEditorialField(page, 'Präsentationstext'); + await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0); + await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau'); await page.reload(); await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); - await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); + await page.getByText('Status, Dauer und Einordnung').click(); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); - await openEditorialField(page, 'Eigene Gedanken & Recherche'); + await openEditorialField(page, 'Private Recherche'); await expect(page.getByLabel('Private Recherche')).toHaveValue( 'Nur intern: **Hintergrund prüfen**.' ); @@ -1089,7 +1090,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy await concurrent.goto(pieceUrl); await 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.getByText('Status, Dauer und Einordnung').click(); await concurrent .getByLabel('Kurzbeschreibung') .fill('Diese veraltete Änderung darf nicht gewinnen.'); @@ -1098,7 +1099,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy }); await concurrent.reload(); await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); - await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).click(); + await concurrent.getByText('Status, Dauer und Einordnung').click(); await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( 'Diese veraltete Änderung darf nicht gewinnen.' ); @@ -1135,9 +1136,8 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy await page.setViewportSize({ width: 390, height: 844 }); await setTheme(page, 'dark'); await page.reload(); - await page.getByRole('button', { name: 'Quellen', exact: true }).click(); + await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'Quellen schließen' }).click(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-mobile-dark.png'), @@ -1155,17 +1155,18 @@ test('a quickly captured video can be written and previewed before classificatio }) ); await page.goto('/eingang'); - await page.getByLabel('Titel oder Gedanke').fill('Schnelles Video-Fundstück'); + await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück'); await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ'); - await page.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', + 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, 'Eigene Gedanken & Recherche'); + await openEditorialField(page, 'Private Recherche'); await page .getByLabel('Private Recherche') .fill('Warum funktioniert der Einstieg so unmittelbar?'); @@ -1173,15 +1174,14 @@ test('a quickly captured video can be written and previewed before classificatio await page .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.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: 'Präsentation testen' }).click(); + 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', @@ -1192,7 +1192,7 @@ test('a quickly captured video can be written and previewed before classificatio await page.setViewportSize({ width: 390, height: 844 }); await page.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 page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await expect(page.getByLabel('Private Recherche')).toBeVisible(); await expectNoHorizontalOverflow(page); }); @@ -1201,21 +1201,21 @@ test('link preview uses one existing source, persists manual fields and isolates page }, testInfo) => { await page.goto('/fundus/piece-console'); - await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click(); + await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByLabel('Linkvorschau').check(); await page.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 page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await expect(page.getByLabel('Linkvorschau')).toBeChecked(); await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue( 'Manuell korrigierte Linkvorschau' ); const previewPromise = page.waitForEvent('popup'); - await page.getByRole('link', { name: 'Präsentation testen' }).click(); + await page.getByRole('link', { name: 'In neuem Fenster testen' }).click(); const preview = await previewPromise; await preview.waitForLoadState('networkidle'); await expect(preview.getByText('Isolierte Testansicht')).toBeVisible(); @@ -1236,8 +1236,10 @@ test('link preview uses one existing source, persists manual fields and isolates }); await preview.close(); + await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); + await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); const blockedPreview = page.waitForResponse((response) => response.url().includes('?/loadPreview') );