Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f466df2c5c | ||
|
|
25da870601 |
No files matched your search
@@ -189,3 +189,29 @@
|
|||||||
Pixeln visuell kontrolliert
|
Pixeln visuell kontrolliert
|
||||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
|
|
||||||
|
## Redaktionelles Piece-Arbeitsblatt
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
|
||||||
|
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
|
||||||
|
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
|
||||||
|
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
|
||||||
|
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen Piece-Arbeitsblatt
|
||||||
|
|
||||||
|
- `npm run lint`: erfolgreich
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm test`: 29 Tests in 6 Dateien erfolgreich
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
|
||||||
|
1 von 1 Browsertest erfolgreich
|
||||||
|
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
|
||||||
|
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
|
||||||
|
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
|
||||||
|
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
|
||||||
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
@@ -239,6 +239,18 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
|
|||||||
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
||||||
ohne interne Anwendungsnavigation.
|
ohne interne Anwendungsnavigation.
|
||||||
|
|
||||||
|
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
|
||||||
|
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
|
||||||
|
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
|
||||||
|
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
|
||||||
|
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
|
||||||
|
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
|
||||||
|
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
|
||||||
|
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
|
||||||
|
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
|
||||||
|
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
|
||||||
|
Mutationen.
|
||||||
|
|
||||||
## Sicherheitsgrenzen
|
## Sicherheitsgrenzen
|
||||||
|
|
||||||
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
|
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ als bereits implementiert verstanden werden.
|
|||||||
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
|
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
|
||||||
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
|
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
|
||||||
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
|
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
|
||||||
|
- Fundus-Originale und sendungsspezifische Verwendungen nutzen denselben
|
||||||
|
großzügigen Dialograhmen mit den Bereichen „Sendungstext“, „Recherche &
|
||||||
|
Quellen“ und „Präsentation“; der jeweilige Speicherkontext ist sichtbar und
|
||||||
|
Original sowie Verwendung bleiben getrennte Revisionsgrenzen
|
||||||
|
- Titel, Einleitung, Moderationsstichpunkte und Überleitung sind ohne
|
||||||
|
vorgeschaltete Lese-/Bearbeitungsschalter direkt beschreibbar; eine kleine
|
||||||
|
Markdown-Leiste ergänzt Formatierung ohne Änderung des Speicherformats
|
||||||
|
- Recherche, Quellen, verwendungseigene Notizen und Material sind in einem
|
||||||
|
Bereich gebündelt; öffentliche Texte, Darstellungswahl und tatsächliche
|
||||||
|
Vorschau stehen gemeinsam im Präsentationsbereich
|
||||||
|
- der Dialog bleibt innerhalb der Desktop-Fensterhöhe, hält Kopf,
|
||||||
|
Bereichsnavigation und Aktionen sichtbar und nutzt mobil die volle
|
||||||
|
Bildschirmfläche; Bereichswechsel hängen Eingaben nicht aus und merken die
|
||||||
|
jeweilige Scrollposition
|
||||||
|
- die Schnellaufnahme akzeptiert Gedanke oder URL, bleibt nach dem Ablegen im
|
||||||
|
Eingang und öffnet die Ausarbeitung erst über eine bewusste Aktion
|
||||||
|
|
||||||
|
Für diese gezielte Überarbeitung wurden `npm run check` sowie kurze
|
||||||
|
Chromium-Sichtkontrollen des Original- und Verwendungseditors bei 1440 × 1000
|
||||||
|
und 390 × 844 Pixeln ausgeführt. Dabei waren Dialoggrenzen, feste Bedienbereiche,
|
||||||
|
alle drei Arbeitsbereiche und horizontaler Überlauf unauffällig. Die vollständige
|
||||||
|
Unit-, E2E- und Build-Matrix ist gemäß Auftrag noch offen.
|
||||||
|
|
||||||
## Umgesetzter Stand Etappe 4
|
## Umgesetzter Stand Etappe 4
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.1",
|
"version": "0.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.1",
|
"version": "0.5.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.2",
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.1",
|
"version": "0.5.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17 <25"
|
"node": ">=22.17 <25"
|
||||||
|
|||||||
+1096
-1
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
value = $bindable(),
|
||||||
|
rows = 7,
|
||||||
|
placeholder = '',
|
||||||
|
privateField = false,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
rows?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
privateField?: boolean;
|
||||||
|
form?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let textarea: HTMLTextAreaElement;
|
||||||
|
|
||||||
|
async function replaceSelection(before: string, after = before, fallback = 'Text') {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selected = value.slice(start, end) || fallback;
|
||||||
|
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
|
||||||
|
await tick();
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addList() {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selected = value.slice(start, end) || 'Stichpunkt';
|
||||||
|
const replacement = selected
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => `- ${line}`)
|
||||||
|
.join('\n');
|
||||||
|
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||||
|
await tick();
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + 2, start + replacement.length);
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class:private-field={privateField} class="markdown-editor-field">
|
||||||
|
<div class="markdown-editor-heading">
|
||||||
|
<span
|
||||||
|
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
|
||||||
|
>
|
||||||
|
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
|
||||||
|
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
|
||||||
|
><strong>B</strong></button
|
||||||
|
>
|
||||||
|
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
|
||||||
|
><em>I</em></button
|
||||||
|
>
|
||||||
|
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Link"
|
||||||
|
title="Link"
|
||||||
|
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:this={textarea}
|
||||||
|
aria-label={label}
|
||||||
|
{name}
|
||||||
|
{rows}
|
||||||
|
{placeholder}
|
||||||
|
{form}
|
||||||
|
maxlength="50000"
|
||||||
|
bind:value></textarea>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick, type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
labelledby,
|
||||||
|
activeSection = $bindable(),
|
||||||
|
onrequestclose,
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
footer
|
||||||
|
}: {
|
||||||
|
labelledby: string;
|
||||||
|
activeSection: 'script' | 'research' | 'presentation';
|
||||||
|
onrequestclose: () => void;
|
||||||
|
header: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
footer: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let workspace: HTMLDivElement;
|
||||||
|
const scrollPositions = { script: 0, research: 0, presentation: 0 };
|
||||||
|
|
||||||
|
async function selectSection(section: typeof activeSection) {
|
||||||
|
scrollPositions[activeSection] = workspace.scrollTop;
|
||||||
|
activeSection = section;
|
||||||
|
await tick();
|
||||||
|
workspace.scrollTop = scrollPositions[section];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showModal() {
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function close() {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="piece-editor-dialog"
|
||||||
|
aria-labelledby={labelledby}
|
||||||
|
oncancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onrequestclose();
|
||||||
|
}}
|
||||||
|
onclick={(event) => event.target === dialog && onrequestclose()}
|
||||||
|
>
|
||||||
|
<header class="piece-dialog-header">
|
||||||
|
<div class="piece-dialog-heading">{@render header()}</div>
|
||||||
|
<button
|
||||||
|
class="dialog-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Editor schließen"
|
||||||
|
onclick={onrequestclose}>×</button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeSection === 'script'}
|
||||||
|
aria-current={activeSection === 'script' ? 'page' : undefined}
|
||||||
|
onclick={() => selectSection('script')}>Sendungstext</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeSection === 'research'}
|
||||||
|
aria-current={activeSection === 'research' ? 'page' : undefined}
|
||||||
|
onclick={() => selectSection('research')}>Recherche & Quellen</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeSection === 'presentation'}
|
||||||
|
aria-current={activeSection === 'presentation' ? 'page' : undefined}
|
||||||
|
onclick={() => selectSection('presentation')}>Präsentation</button
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
|
||||||
|
<footer class="piece-dialog-footer">{@render footer()}</footer>
|
||||||
|
</dialog>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance, type SubmitFunction } from '$app/forms';
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import MarkdownEditorField from './MarkdownEditorField.svelte';
|
||||||
import NavIcon from './NavIcon.svelte';
|
import NavIcon from './NavIcon.svelte';
|
||||||
|
import PieceEditorDialog from './PieceEditorDialog.svelte';
|
||||||
import type { PieceUsage } from '#lib/server/shows.js';
|
import type { PieceUsage } from '#lib/server/shows.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -19,12 +21,30 @@
|
|||||||
error?: string;
|
error?: string;
|
||||||
enhanceCommand: SubmitFunction;
|
enhanceCommand: SubmitFunction;
|
||||||
} = $props();
|
} = $props();
|
||||||
let dialog: HTMLDialogElement;
|
|
||||||
let presentationMode = $state(untrack(() => usage.presentationMode));
|
let formId = $derived(`usage-editor-form-${usage.id}`);
|
||||||
|
let editor: { showModal: () => void; close: () => void };
|
||||||
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||||
|
let draft = $state(untrack(() => ({ ...usage })));
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
function durationInMinutes(seconds: number) {
|
function durationInMinutes(seconds: number) {
|
||||||
return Number((seconds / 60).toFixed(2));
|
return Number((seconds / 60).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
|
||||||
|
dirty = false;
|
||||||
|
editor.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhanceEditor: SubmitFunction = async (input) => {
|
||||||
|
const finish = await enhanceCommand(input);
|
||||||
|
return async (result) => {
|
||||||
|
if (finish) await finish(result);
|
||||||
|
if (result.result.type === 'success') dirty = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -32,45 +52,79 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${usage.title} bearbeiten und verschieben`}
|
aria-label={`${usage.title} bearbeiten und verschieben`}
|
||||||
title="Bearbeiten und verschieben"
|
title="Bearbeiten und verschieben"
|
||||||
onclick={() => dialog.showModal()}
|
onclick={() => editor.showModal()}
|
||||||
>
|
>
|
||||||
<NavIcon name="edit" />
|
<NavIcon name="edit" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<dialog
|
<PieceEditorDialog
|
||||||
bind:this={dialog}
|
bind:this={editor}
|
||||||
class="usage-editor-dialog"
|
bind:activeSection
|
||||||
aria-labelledby={`usage-editor-title-${usage.id}`}
|
labelledby={`usage-editor-title-${usage.id}`}
|
||||||
onclick={(event) => event.target === dialog && dialog.close()}
|
onrequestclose={requestClose}
|
||||||
>
|
>
|
||||||
<header>
|
{#snippet header()}
|
||||||
<div>
|
<p class="eyebrow">Verwendung in dieser Sendung</p>
|
||||||
<p class="eyebrow">Sendungsspezifische Kopie</p>
|
<label class="piece-dialog-title">
|
||||||
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
|
<span class="sr-only">Titel</span>
|
||||||
</div>
|
<textarea
|
||||||
<form method="dialog">
|
form={formId}
|
||||||
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
|
name="title"
|
||||||
</form>
|
required
|
||||||
</header>
|
rows="1"
|
||||||
<div class="usage-dialog-scroll">
|
maxlength="200"
|
||||||
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
|
aria-label="Titel"
|
||||||
<form
|
bind:value={draft.title}
|
||||||
use:enhance={enhanceCommand}
|
oninput={() => (dirty = true)}></textarea>
|
||||||
method="POST"
|
</label>
|
||||||
action="?/updateUsage"
|
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
|
||||||
class="usage-dialog-editor"
|
<small>Änderungen gelten nur für diese Einplanung.</small>
|
||||||
>
|
{/snippet}
|
||||||
<input type="hidden" name="revision" value={revision} />
|
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<form
|
||||||
<section class="editor-section">
|
id={formId}
|
||||||
<div class="section-title">
|
use:enhance={enhanceEditor}
|
||||||
<p class="eyebrow">Grundlagen</p>
|
method="POST"
|
||||||
<h3>Titel, Dauer und Notizen</h3>
|
action="?/updateUsage"
|
||||||
|
class="piece-dialog-form"
|
||||||
|
oninput={() => (dirty = true)}
|
||||||
|
onchange={() => (dirty = true)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="revision" value={revision} />
|
||||||
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
|
|
||||||
|
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
|
||||||
|
<div class="piece-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Für die Moderation</p>
|
||||||
|
<h3>Sendungstext</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-fields two-columns">
|
<small>Die drei Teile bleiben getrennt gespeichert.</small>
|
||||||
<label class="wide-field"
|
</div>
|
||||||
>Titel<input name="title" required maxlength="200" value={usage.title} /></label
|
<div class="piece-writing-stack">
|
||||||
>
|
<MarkdownEditorField
|
||||||
|
name="intro"
|
||||||
|
label="Einleitung"
|
||||||
|
placeholder="Wie beginnt dieses Thema in der Sendung?"
|
||||||
|
bind:value={draft.intro}
|
||||||
|
/>
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="moderation"
|
||||||
|
label="Moderationsstichpunkte"
|
||||||
|
placeholder="Argumente, Fragen und Ablaufpunkte"
|
||||||
|
rows={10}
|
||||||
|
bind:value={draft.moderation}
|
||||||
|
/>
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="transition"
|
||||||
|
label="Überleitung"
|
||||||
|
placeholder="Wie geht es zum nächsten Thema weiter?"
|
||||||
|
bind:value={draft.transition}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<details class="piece-meta-details">
|
||||||
|
<summary>Dauer und Einplanung</summary>
|
||||||
|
<div class="piece-meta-grid">
|
||||||
<label
|
<label
|
||||||
>Dauer in Minuten<input
|
>Dauer in Minuten<input
|
||||||
name="durationMinutes"
|
name="durationMinutes"
|
||||||
@@ -81,136 +135,182 @@
|
|||||||
value={durationInMinutes(usage.durationSeconds)}
|
value={durationInMinutes(usage.durationSeconds)}
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
<label class="wide-field"
|
|
||||||
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
|
|
||||||
>{usage.ownNotes}</textarea
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="editor-section markdown-section">
|
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
|
||||||
<div class="section-title">
|
<div class="piece-panel-heading">
|
||||||
<p class="eyebrow">Redaktion</p>
|
<div>
|
||||||
<h3>Getrennte Arbeitsbereiche</h3>
|
<p class="eyebrow">Nicht öffentlich</p>
|
||||||
|
<h3>Recherche & Quellen</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="markdown-grid">
|
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
|
||||||
<label
|
</div>
|
||||||
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
|
<div class="piece-writing-stack">
|
||||||
>{usage.intro}</textarea
|
<MarkdownEditorField
|
||||||
></label
|
name="research"
|
||||||
>
|
label="Private Recherche"
|
||||||
<label class="private-field"
|
placeholder="Hintergrund, Prüfung und offene Fragen"
|
||||||
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
|
rows={10}
|
||||||
>{usage.research}</textarea
|
privateField
|
||||||
></label
|
bind:value={draft.research}
|
||||||
>
|
/>
|
||||||
<label
|
<MarkdownEditorField
|
||||||
><span>Moderationsstichpunkte</span><textarea
|
name="ownNotes"
|
||||||
name="moderation"
|
label="Interne Notizen zu dieser Verwendung"
|
||||||
rows="7"
|
placeholder="Absprachen und Hinweise nur für diese Sendung"
|
||||||
maxlength="50000">{usage.moderation}</textarea
|
privateField
|
||||||
></label
|
bind:value={draft.ownNotes}
|
||||||
>
|
/>
|
||||||
<label
|
</div>
|
||||||
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
|
<section class="piece-source-summary">
|
||||||
>{usage.transition}</textarea
|
<p class="eyebrow">Snapshot-Quelle</p>
|
||||||
></label
|
{#if draft.presentationSourceUrl}
|
||||||
>
|
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
|
||||||
<label class="wide-field"
|
<small>{draft.presentationSourceUrl}</small>
|
||||||
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
|
{:else}
|
||||||
>{usage.presentation}</textarea
|
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
|
||||||
></label
|
{/if}
|
||||||
>
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
||||||
|
<div class="piece-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Öffentliche Ausgabe</p>
|
||||||
|
<h3>Präsentation</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="presentation-settings">
|
<small>Private Recherche und Notizen werden nicht ausgegeben.</small>
|
||||||
<label
|
</div>
|
||||||
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
|
<div class="piece-presentation-layout">
|
||||||
<option value="text">Text</option>
|
<div class="piece-presentation-controls">
|
||||||
<option value="link">Linkvorschau</option>
|
<MarkdownEditorField
|
||||||
<option value="video">Video</option>
|
name="presentation"
|
||||||
</select></label
|
label="Öffentlicher Text"
|
||||||
>
|
placeholder="Text für die Publikumsausgabe"
|
||||||
{#if presentationMode !== 'text'}
|
bind:value={draft.presentation}
|
||||||
<input
|
/>
|
||||||
type="hidden"
|
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||||
name="presentationSourceLabel"
|
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
|
||||||
value={usage.presentationSourceLabel}
|
<label
|
||||||
/>
|
><input
|
||||||
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
|
type="radio"
|
||||||
<input
|
name="presentationMode"
|
||||||
type="hidden"
|
value={mode[0]}
|
||||||
name="presentationSourceTimecodeSeconds"
|
bind:group={draft.presentationMode}
|
||||||
value={usage.presentationSourceTimecodeSeconds ?? ''}
|
/>{mode[1]}</label
|
||||||
/>
|
>
|
||||||
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
|
{/each}
|
||||||
<div class="presentation-source-summary wide-field">
|
</div>
|
||||||
<span>Ausgewählte Quelle</span>
|
<input
|
||||||
<strong
|
type="hidden"
|
||||||
>{usage.presentationSourceLabel ||
|
name="presentationSourceLabel"
|
||||||
usage.presentationSourceUrl ||
|
value={draft.presentationSourceLabel}
|
||||||
'Keine Quelle'}</strong
|
/>
|
||||||
|
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationSourceTimecodeSeconds"
|
||||||
|
value={draft.presentationSourceTimecodeSeconds ?? ''}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||||
|
{#if draft.presentationMode !== 'text'}
|
||||||
|
<div class="presentation-settings">
|
||||||
|
<div class="presentation-source-summary wide-field">
|
||||||
|
<span>Ausgewählte Quelle</span>
|
||||||
|
<strong
|
||||||
|
>{draft.presentationSourceLabel ||
|
||||||
|
draft.presentationSourceUrl ||
|
||||||
|
'Keine Quelle'}</strong
|
||||||
|
>
|
||||||
|
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Titel<input
|
||||||
|
name="presentationTitle"
|
||||||
|
maxlength="300"
|
||||||
|
bind:value={draft.presentationTitle}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Bild-URL<input
|
||||||
|
name="presentationImageUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
bind:value={draft.presentationImageUrl}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide-field"
|
||||||
|
>Anreißer<textarea
|
||||||
|
name="presentationDescription"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
bind:value={draft.presentationDescription}></textarea></label
|
||||||
>
|
>
|
||||||
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<label
|
{:else}
|
||||||
>Titel<input
|
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
|
||||||
name="presentationTitle"
|
<input
|
||||||
maxlength="300"
|
type="hidden"
|
||||||
value={usage.presentationTitle}
|
name="presentationDescription"
|
||||||
/></label
|
value={draft.presentationDescription}
|
||||||
>
|
/>
|
||||||
<label
|
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
||||||
>Bild-URL<input
|
|
||||||
name="presentationImageUrl"
|
|
||||||
type="url"
|
|
||||||
maxlength="2000"
|
|
||||||
value={usage.presentationImageUrl}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label class="wide-field"
|
|
||||||
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
|
|
||||||
>{usage.presentationDescription}</textarea
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
||||||
|
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
|
||||||
<button class="primary-button" type="submit">Verwendung speichern</button>
|
<h4>{draft.presentationTitle || draft.title}</h4>
|
||||||
</form>
|
{#if draft.presentationMode === 'text'}
|
||||||
|
<p class="preview-markdown-source">
|
||||||
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
|
{draft.presentation || 'Noch kein öffentlicher Text.'}
|
||||||
<div>
|
</p>
|
||||||
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
|
{:else}
|
||||||
<input type="hidden" name="revision" value={revision} />
|
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<p>
|
||||||
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
|
{draft.presentationDescription ||
|
||||||
>Aus Fundus aktualisieren</button
|
draft.presentationSourceUrl ||
|
||||||
>
|
'Noch keine Vorschauangaben.'}
|
||||||
</form>
|
</p>
|
||||||
<small
|
{/if}
|
||||||
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
|
</aside>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="piece-dialog-secondary-actions">
|
||||||
|
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
|
||||||
<input type="hidden" name="revision" value={revision} />
|
<input type="hidden" name="revision" value={revision} />
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
<button class="secondary-button" type="submit">Duplizieren</button>
|
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
|
||||||
|
>Aus Fundus aktualisieren</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
|
||||||
|
<input type="hidden" name="revision" value={revision} /><input
|
||||||
|
type="hidden"
|
||||||
|
name="usageId"
|
||||||
|
value={usage.id}
|
||||||
|
/>
|
||||||
|
<button class="text-button" type="submit" disabled={dirty}>Duplizieren</button>
|
||||||
</form>
|
</form>
|
||||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
||||||
<input type="hidden" name="revision" value={revision} />
|
<input type="hidden" name="revision" value={revision} /><input
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
type="hidden"
|
||||||
<input type="hidden" name="position" value="9999" />
|
name="usageId"
|
||||||
|
value={usage.id}
|
||||||
|
/><input type="hidden" name="position" value="9999" />
|
||||||
<label
|
<label
|
||||||
>Ziel<select name="sectionId">
|
><span class="sr-only">Ziel</span><select name="sectionId"
|
||||||
<option value="">Reservebank</option>
|
><option value="">Reservebank</option>{#each sections as target (target.id)}<option
|
||||||
{#each sections as target (target.id)}
|
value={target.id}
|
||||||
<option value={target.id} selected={target.id === sectionId}>{target.name}</option>
|
selected={target.id === sectionId}>{target.name}</option
|
||||||
{/each}
|
>{/each}</select
|
||||||
</select></label
|
></label
|
||||||
>
|
>
|
||||||
<button class="secondary-button" type="submit">Verschieben</button>
|
<button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
|
||||||
</form>
|
</form>
|
||||||
<form
|
<form
|
||||||
use:enhance={enhanceCommand}
|
use:enhance={enhanceCommand}
|
||||||
@@ -219,10 +319,17 @@
|
|||||||
onsubmit={(event) =>
|
onsubmit={(event) =>
|
||||||
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
|
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="revision" value={revision} />
|
<input type="hidden" name="revision" value={revision} /><input
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
type="hidden"
|
||||||
<button class="text-button danger-text" type="submit">Entfernen</button>
|
name="usageId"
|
||||||
|
value={usage.id}
|
||||||
|
/>
|
||||||
|
<button class="text-button danger-text" type="submit" disabled={dirty}>Entfernen</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
<div class="piece-dialog-primary-action">
|
||||||
</dialog>
|
{#if error}<span class="problem" role="alert">{error}</span>{/if}
|
||||||
|
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PieceEditorDialog>
|
||||||
@@ -620,10 +620,11 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function captureInbox(titleValue: string, urlValue: string): string {
|
export function captureInbox(titleValue: string, urlValue: string): string {
|
||||||
const title = cleanText(titleValue, 200, 'title');
|
let title = cleanText(titleValue, 200, 'title');
|
||||||
if (!title) throw new Error('INVALID_TITLE');
|
|
||||||
const url = cleanText(urlValue, 2_000, 'source');
|
const url = cleanText(urlValue, 2_000, 'source');
|
||||||
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
|
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
|
||||||
|
if (!title && !url) throw new Error('INVALID_TITLE');
|
||||||
|
if (!title) title = new URL(url).hostname;
|
||||||
const id = createPiece('blank');
|
const id = createPiece('blank');
|
||||||
const piece = getPiece(id)!;
|
const piece = getPiece(id)!;
|
||||||
savePiece(id, piece.revision, {
|
savePiece(id, piece.revision, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { requireUser } from '#lib/server/auth.js';
|
import { requireUser } from '#lib/server/auth.js';
|
||||||
import { captureInbox, listPieces } from '#lib/server/content.js';
|
import { captureInbox, listPieces } from '#lib/server/content.js';
|
||||||
@@ -14,7 +14,7 @@ export const actions = {
|
|||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
try {
|
try {
|
||||||
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
|
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
|
||||||
redirect(303, `/fundus/${id}`);
|
return { captured: true, capturedId: id };
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const code = cause instanceof Error ? cause.message : '';
|
const code = cause instanceof Error ? cause.message : '';
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
|
|||||||
@@ -17,11 +17,14 @@
|
|||||||
<h2>Was soll nicht verloren gehen?</h2>
|
<h2>Was soll nicht verloren gehen?</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||||
|
{#if form?.captured}<div class="form-message success" role="status">
|
||||||
|
Im Eingang gespeichert.
|
||||||
|
<a href={resolve('/fundus/[id]', { id: form.capturedId })}>Jetzt ausarbeiten</a>
|
||||||
|
</div>{/if}
|
||||||
<form method="POST" action="?/capture" class="capture-form">
|
<form method="POST" action="?/capture" class="capture-form">
|
||||||
<label
|
<label
|
||||||
>Titel oder Gedanke<input
|
>Gedanke <span>optional bei URL</span><input
|
||||||
name="title"
|
name="title"
|
||||||
required
|
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
value={form?.title ?? ''}
|
value={form?.title ?? ''}
|
||||||
placeholder="Kurze Idee festhalten"
|
placeholder="Kurze Idee festhalten"
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
<button class="primary-button" type="submit">Aufnehmen und öffnen</button>
|
<button class="primary-button" type="submit">Im Eingang ablegen</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{#if data.pieces.length}
|
{#if data.pieces.length}
|
||||||
|
|||||||
+445
-359
@@ -2,8 +2,11 @@
|
|||||||
import { enhance, type SubmitFunction } from '$app/forms';
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
|
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
|
||||||
|
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
|
||||||
|
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
|
||||||
|
import type { PresentationContent } from '#lib/server/presentation.js';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
@@ -12,8 +15,11 @@
|
|||||||
let revision = $state(untrack(() => data.piece.revision));
|
let revision = $state(untrack(() => data.piece.revision));
|
||||||
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
||||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||||
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||||
let saveMessage = $state('Alle Änderungen gespeichert');
|
let saveMessage = $state('Alle Änderungen gespeichert');
|
||||||
|
let sourceImageUnavailable = $state(false);
|
||||||
|
let editorDialog: { showModal: () => void; close: () => void };
|
||||||
let editorForm: HTMLFormElement;
|
let editorForm: HTMLFormElement;
|
||||||
let previewForm: HTMLFormElement;
|
let previewForm: HTMLFormElement;
|
||||||
let saveButton: HTMLButtonElement;
|
let saveButton: HTMLButtonElement;
|
||||||
@@ -26,9 +32,50 @@
|
|||||||
saveState === 'error' ||
|
saveState === 'error' ||
|
||||||
saveState === 'conflict'
|
saveState === 'conflict'
|
||||||
);
|
);
|
||||||
|
let featuredSource = $derived(
|
||||||
|
draft.presentationSourcePosition === null
|
||||||
|
? (sources[0] ?? null)
|
||||||
|
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
|
||||||
|
);
|
||||||
|
let sourcePreviewImage = $derived(
|
||||||
|
draft.presentationImageUrl
|
||||||
|
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
let previewContent = $derived<PresentationContent>({
|
||||||
|
mode: draft.presentationMode,
|
||||||
|
title: draft.title,
|
||||||
|
html: rendered.presentation,
|
||||||
|
source: featuredSource
|
||||||
|
? {
|
||||||
|
label: featuredSource.label || featuredSource.url,
|
||||||
|
url: featuredSource.url,
|
||||||
|
timecodeSeconds: featuredSource.timecodeSeconds
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
previewTitle: draft.presentationTitle,
|
||||||
|
description: draft.presentationDescription,
|
||||||
|
imageUrl: draft.presentationImageUrl,
|
||||||
|
siteName: draft.presentationSiteName,
|
||||||
|
provider: draft.presentationProvider as PresentationContent['provider'],
|
||||||
|
providerId: draft.presentationProviderId,
|
||||||
|
embedUrl:
|
||||||
|
draft.presentationProvider === 'youtube' && draft.presentationProviderId
|
||||||
|
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
|
||||||
|
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
|
||||||
|
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
|
||||||
|
: ''
|
||||||
|
});
|
||||||
let pendingHref: string | null = null;
|
let pendingHref: string | null = null;
|
||||||
let allowNavigation = false;
|
let allowNavigation = false;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
inbox: 'Eingang',
|
||||||
|
preparing: 'In Vorbereitung',
|
||||||
|
ready: 'Sendefertig'
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => editorDialog.showModal());
|
||||||
onDestroy(() => clearTimeout(timer));
|
onDestroy(() => clearTimeout(timer));
|
||||||
beforeNavigate((navigation) => {
|
beforeNavigate((navigation) => {
|
||||||
if (allowNavigation) return;
|
if (allowNavigation) return;
|
||||||
@@ -66,6 +113,23 @@
|
|||||||
editorForm.requestSubmit(saveButton);
|
editorForm.requestSubmit(saveButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
const target = resolve('/fundus');
|
||||||
|
if (saveState === 'error' || saveState === 'conflict') {
|
||||||
|
if (!confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
|
||||||
|
allowNavigation = true;
|
||||||
|
void goto(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (saveState === 'dirty' || saveState === 'saving') {
|
||||||
|
pendingHref = new URL(target, window.location.href).href;
|
||||||
|
if (!saving) submitSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allowNavigation = true;
|
||||||
|
void goto(target);
|
||||||
|
}
|
||||||
|
|
||||||
function addSource() {
|
function addSource() {
|
||||||
sources.push({ label: '', url: '', timecodeSeconds: null });
|
sources.push({ label: '', url: '', timecodeSeconds: null });
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
@@ -87,6 +151,7 @@
|
|||||||
draft.presentationMetadataFetchedAtUtc = null;
|
draft.presentationMetadataFetchedAtUtc = null;
|
||||||
draft.presentationProvider = '';
|
draft.presentationProvider = '';
|
||||||
draft.presentationProviderId = '';
|
draft.presentationProviderId = '';
|
||||||
|
sourceImageUnavailable = false;
|
||||||
if (!draft.presentationTitleManual) draft.presentationTitle = '';
|
if (!draft.presentationTitleManual) draft.presentationTitle = '';
|
||||||
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
|
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
|
||||||
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
|
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
|
||||||
@@ -133,319 +198,398 @@
|
|||||||
|
|
||||||
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
|
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
|
||||||
|
|
||||||
<div class="page-wrap editor-page">
|
<div class="piece-editor-route">
|
||||||
<header class="editor-header">
|
{#if form?.error}<div class="form-message error route-editor-error" role="alert">
|
||||||
<div>
|
{form.error}
|
||||||
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
|
</div>{/if}
|
||||||
<h1>{draft.title}</h1>
|
<PieceEditorDialog
|
||||||
</div>
|
bind:this={editorDialog}
|
||||||
<div
|
bind:activeSection
|
||||||
class:problem={saveState === 'error' || saveState === 'conflict'}
|
labelledby="piece-editor-title"
|
||||||
class="save-indicator"
|
onrequestclose={requestClose}
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<i></i><span>{saveMessage}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
|
||||||
|
|
||||||
<form
|
|
||||||
bind:this={editorForm}
|
|
||||||
use:enhance={enhanceSave}
|
|
||||||
method="POST"
|
|
||||||
action="?/save"
|
|
||||||
class="piece-editor"
|
|
||||||
oninput={scheduleSave}
|
|
||||||
onchange={scheduleSave}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="revision" value={revision} />
|
{#snippet header()}
|
||||||
<section class="editor-section editor-basics">
|
<p class="eyebrow">Fundus-Original</p>
|
||||||
<div class="section-title">
|
<label class="piece-dialog-title">
|
||||||
<p class="eyebrow">Grundlagen</p>
|
<span class="sr-only">Titel</span>
|
||||||
<h2>Worum geht es?</h2>
|
<textarea
|
||||||
</div>
|
form="piece-editor-form"
|
||||||
<div class="editor-fields two-columns">
|
name="title"
|
||||||
<label class="wide-field"
|
required
|
||||||
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
|
rows="1"
|
||||||
>
|
maxlength="200"
|
||||||
<label
|
aria-label="Titel"
|
||||||
>Status<select name="status" bind:value={draft.status}
|
bind:value={draft.title}
|
||||||
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
|
oninput={scheduleSave}></textarea>
|
||||||
><option value="ready">Sendefertig</option></select
|
</label>
|
||||||
></label
|
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
|
||||||
>
|
<small>Original im Fundus · Änderungen wirken nicht rückwirkend auf Einplanungen.</small>
|
||||||
<label
|
{/snippet}
|
||||||
>Format<select name="formatId" bind:value={draft.formatId}
|
|
||||||
><option value={null}>Ohne Format</option
|
|
||||||
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
|
|
||||||
>{item.name}</option
|
|
||||||
>{/each}</select
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Dauer in Minuten<input
|
|
||||||
name="durationMinutes"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="1440"
|
|
||||||
step="any"
|
|
||||||
bind:value={durationMinutes}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label class="wide-field"
|
|
||||||
>Kurzbeschreibung<textarea
|
|
||||||
name="summary"
|
|
||||||
rows="2"
|
|
||||||
maxlength="2000"
|
|
||||||
bind:value={draft.summary}></textarea></label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-section taxonomy-editor">
|
<form
|
||||||
<div class="section-title">
|
bind:this={editorForm}
|
||||||
<p class="eyebrow">Einordnung</p>
|
id="piece-editor-form"
|
||||||
<h2>Themen und Abschnitte</h2>
|
use:enhance={enhanceSave}
|
||||||
</div>
|
method="POST"
|
||||||
<fieldset>
|
action="?/save"
|
||||||
<legend>Themen</legend>
|
class="piece-dialog-form"
|
||||||
<div class="choice-grid">
|
oninput={scheduleSave}
|
||||||
{#each data.taxonomy.topics as item (item.id)}<label
|
onchange={scheduleSave}
|
||||||
><input
|
>
|
||||||
type="checkbox"
|
<input type="hidden" name="revision" value={revision} />
|
||||||
name="topicIds"
|
|
||||||
value={item.id}
|
|
||||||
bind:group={draft.topicIds}
|
|
||||||
/>{item.name}</label
|
|
||||||
>{/each}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset>
|
|
||||||
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
|
||||||
<div class="choice-grid">
|
|
||||||
{#each data.taxonomy.categories as item (item.id)}<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
name="categoryIds"
|
|
||||||
value={item.id}
|
|
||||||
bind:group={draft.categoryIds}
|
|
||||||
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
|
|
||||||
>{/each}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-section markdown-section">
|
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
|
||||||
<div class="section-title">
|
<div class="piece-panel-heading">
|
||||||
<p class="eyebrow">Redaktion</p>
|
<div>
|
||||||
<h2>Getrennte Arbeitsbereiche</h2>
|
<p class="eyebrow">Für die Moderation</p>
|
||||||
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
|
<h2>Sendungstext</h2>
|
||||||
</div>
|
|
||||||
<div class="markdown-grid">
|
|
||||||
<label
|
|
||||||
><span>Einleitung</span><textarea
|
|
||||||
name="intro"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.intro}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.intro} /></label
|
|
||||||
>
|
|
||||||
<label class="private-field"
|
|
||||||
><span>Private Recherche</span><textarea
|
|
||||||
name="researchText"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.researchText}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.research} /></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
><span>Moderationsstichpunkte</span><textarea
|
|
||||||
name="moderationNotes"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.moderationNotes}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.moderation} /></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
><span>Überleitung</span><textarea
|
|
||||||
name="transitionText"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.transitionText}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.transition} /></label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-section presentation-editor">
|
|
||||||
<div class="section-title split-section-title">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Präsentation</p>
|
|
||||||
<h2>Darstellung in heinzlive</h2>
|
|
||||||
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
class="secondary-button"
|
|
||||||
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener">Präsentation testen</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
|
||||||
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="radio"
|
|
||||||
name="presentationMode"
|
|
||||||
value={mode[0]}
|
|
||||||
bind:group={draft.presentationMode}
|
|
||||||
/>{mode[1]}</label
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if draft.presentationMode === 'text'}
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="presentationSourcePosition"
|
|
||||||
value={draft.presentationSourcePosition ?? ''}
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
|
|
||||||
<input type="hidden" name="presentationDescription" value={draft.presentationDescription} />
|
|
||||||
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
|
||||||
<label class="presentation-text-field"
|
|
||||||
><span>Präsentationstext</span><textarea
|
|
||||||
name="presentationText"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.presentationText}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.presentation} /></label
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<input type="hidden" name="presentationText" value={draft.presentationText} />
|
|
||||||
<div class="presentation-settings">
|
|
||||||
<label class="wide-field"
|
|
||||||
>Quelle<select
|
|
||||||
name="presentationSourcePosition"
|
|
||||||
bind:value={draft.presentationSourcePosition}
|
|
||||||
onchange={presentationSourceChanged}
|
|
||||||
>
|
|
||||||
<option value={null}>Quelle auswählen</option>
|
|
||||||
{#each sources as source, index (index)}
|
|
||||||
{#if source.url}<option value={index}>{source.label || source.url}</option>{/if}
|
|
||||||
{/each}
|
|
||||||
</select></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Titel<input
|
|
||||||
name="presentationTitle"
|
|
||||||
maxlength="300"
|
|
||||||
bind:value={draft.presentationTitle}
|
|
||||||
oninput={() => (draft.presentationTitleManual = true)}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Bild-URL<input
|
|
||||||
name="presentationImageUrl"
|
|
||||||
type="url"
|
|
||||||
maxlength="2000"
|
|
||||||
bind:value={draft.presentationImageUrl}
|
|
||||||
oninput={() => (draft.presentationImageManual = true)}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label class="wide-field"
|
|
||||||
>Anreißer<textarea
|
|
||||||
name="presentationDescription"
|
|
||||||
rows="3"
|
|
||||||
maxlength="1000"
|
|
||||||
bind:value={draft.presentationDescription}
|
|
||||||
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
|
|
||||||
>
|
|
||||||
<div class="presentation-preview-actions wide-field">
|
|
||||||
<button
|
|
||||||
class="secondary-button"
|
|
||||||
type="button"
|
|
||||||
onclick={() => previewForm.requestSubmit()}
|
|
||||||
disabled={actionsBlocked || draft.presentationSourcePosition === null}
|
|
||||||
>Vorschau laden</button
|
|
||||||
>
|
|
||||||
<small>
|
|
||||||
{draft.presentationMetadataFetchedAtUtc
|
|
||||||
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
|
|
||||||
: 'Noch keine Metadaten geladen.'}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
<small>Die drei Teile bleiben getrennt gespeichert.</small>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="piece-writing-stack">
|
||||||
<input
|
<MarkdownEditorField
|
||||||
type="hidden"
|
name="intro"
|
||||||
name="presentationTitleManual"
|
label="Einleitung"
|
||||||
value={draft.presentationTitleManual ? '1' : '0'}
|
placeholder="Wie beginnt dieses Thema in der Sendung?"
|
||||||
/>
|
bind:value={draft.intro}
|
||||||
<input
|
/>
|
||||||
type="hidden"
|
<MarkdownEditorField
|
||||||
name="presentationDescriptionManual"
|
name="moderationNotes"
|
||||||
value={draft.presentationDescriptionManual ? '1' : '0'}
|
label="Moderationsstichpunkte"
|
||||||
/>
|
placeholder="Argumente, Fragen und Ablaufpunkte"
|
||||||
<input
|
rows={10}
|
||||||
type="hidden"
|
bind:value={draft.moderationNotes}
|
||||||
name="presentationImageManual"
|
/>
|
||||||
value={draft.presentationImageManual ? '1' : '0'}
|
<MarkdownEditorField
|
||||||
/>
|
name="transitionText"
|
||||||
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
label="Überleitung"
|
||||||
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
|
placeholder="Wie geht es zum nächsten Thema weiter?"
|
||||||
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
|
bind:value={draft.transitionText}
|
||||||
<input
|
/>
|
||||||
type="hidden"
|
</div>
|
||||||
name="presentationMetadataFetchedAtUtc"
|
<details class="piece-meta-details">
|
||||||
value={draft.presentationMetadataFetchedAtUtc ?? ''}
|
<summary>
|
||||||
/>
|
<span>Status, Dauer und Einordnung</span>
|
||||||
</section>
|
<small
|
||||||
|
>{statusLabels[draft.status as keyof typeof statusLabels]} · {durationMinutes || 0} Min.</small
|
||||||
|
>
|
||||||
|
</summary>
|
||||||
|
<div class="piece-meta-grid">
|
||||||
|
<label
|
||||||
|
>Status<select name="status" bind:value={draft.status}
|
||||||
|
><option value="inbox">Eingang</option><option value="preparing"
|
||||||
|
>In Vorbereitung</option
|
||||||
|
><option value="ready">Sendefertig</option></select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Dauer in Minuten<input
|
||||||
|
name="durationMinutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1440"
|
||||||
|
step="any"
|
||||||
|
bind:value={durationMinutes}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Format<select name="formatId" bind:value={draft.formatId}
|
||||||
|
><option value={null}>Ohne Format</option
|
||||||
|
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
|
||||||
|
>{item.name}</option
|
||||||
|
>{/each}</select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label class="wide-field"
|
||||||
|
>Kurzbeschreibung<textarea
|
||||||
|
name="summary"
|
||||||
|
rows="3"
|
||||||
|
maxlength="2000"
|
||||||
|
bind:value={draft.summary}></textarea></label
|
||||||
|
>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Themen</legend>
|
||||||
|
<div class="choice-grid">
|
||||||
|
{#each data.taxonomy.topics as item (item.id)}<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
name="topicIds"
|
||||||
|
value={item.id}
|
||||||
|
bind:group={draft.topicIds}
|
||||||
|
/>{item.name}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
||||||
|
<div class="choice-grid">
|
||||||
|
{#each data.taxonomy.categories as item (item.id)}<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
name="categoryIds"
|
||||||
|
value={item.id}
|
||||||
|
bind:group={draft.categoryIds}
|
||||||
|
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="editor-section source-editor">
|
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
|
||||||
<div class="section-title split-section-title">
|
<div class="piece-panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Belege</p>
|
<p class="eyebrow">Nicht öffentlich</p>
|
||||||
<h2>Quellen</h2>
|
<h2>Recherche & Quellen</h2>
|
||||||
|
</div>
|
||||||
|
<small>Recherche und Material bleiben innerhalb der Redaktion.</small>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
|
<MarkdownEditorField
|
||||||
>
|
name="researchText"
|
||||||
</div>
|
label="Private Recherche"
|
||||||
{#if sources.length}<div class="source-list">
|
placeholder="Hintergrund, Prüfung und offene Fragen"
|
||||||
{#each sources as source, index (index)}<div class="source-row">
|
rows={10}
|
||||||
|
privateField
|
||||||
|
bind:value={draft.researchText}
|
||||||
|
/>
|
||||||
|
<section class="piece-source-editor">
|
||||||
|
<div class="piece-subsection-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Fundstellen</p>
|
||||||
|
<h3>Quellen</h3>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" type="button" onclick={addSource}
|
||||||
|
>Quelle hinzufügen</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if sources.length}<div class="source-list">
|
||||||
|
{#each sources as source, index (index)}<div class="source-row">
|
||||||
|
<label
|
||||||
|
>Name<input
|
||||||
|
name="sourceLabel"
|
||||||
|
maxlength="200"
|
||||||
|
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}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
||||||
|
<div class="piece-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Öffentliche Ausgabe</p>
|
||||||
|
<h2>Präsentation</h2>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="secondary-button"
|
||||||
|
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">In neuem Fenster testen</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="piece-presentation-layout">
|
||||||
|
<div class="piece-presentation-controls">
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="presentationText"
|
||||||
|
label="Öffentlicher Text"
|
||||||
|
placeholder="Text für die Publikumsausgabe"
|
||||||
|
rows={8}
|
||||||
|
bind:value={draft.presentationText}
|
||||||
|
/>
|
||||||
|
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||||
|
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="presentationMode"
|
||||||
|
value={mode[0]}
|
||||||
|
bind:group={draft.presentationMode}
|
||||||
|
/>{mode[1]}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
<div class="presentation-settings">
|
||||||
<label
|
<label
|
||||||
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
|
>Quelle<select
|
||||||
><label
|
name="presentationSourcePosition"
|
||||||
>URL<input
|
bind:value={draft.presentationSourcePosition}
|
||||||
name="sourceUrl"
|
onchange={presentationSourceChanged}
|
||||||
|
><option value={null}>Quelle auswählen</option
|
||||||
|
>{#each sources as source, index (index)}{#if source.url}<option value={index}
|
||||||
|
>{source.label || source.url}</option
|
||||||
|
>{/if}{/each}</select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Titel<input
|
||||||
|
name="presentationTitle"
|
||||||
|
maxlength="300"
|
||||||
|
bind:value={draft.presentationTitle}
|
||||||
|
oninput={() => (draft.presentationTitleManual = true)}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Bild-URL<input
|
||||||
|
name="presentationImageUrl"
|
||||||
type="url"
|
type="url"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
bind:value={source.url}
|
bind:value={draft.presentationImageUrl}
|
||||||
|
oninput={() => (draft.presentationImageManual = true)}
|
||||||
/></label
|
/></label
|
||||||
><label
|
|
||||||
>Zeitmarke in Sekunden<input
|
|
||||||
name="sourceTimecode"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="86400"
|
|
||||||
bind:value={source.timecodeSeconds}
|
|
||||||
/></label
|
|
||||||
><button
|
|
||||||
class="text-button danger-text"
|
|
||||||
type="button"
|
|
||||||
onclick={() => removeSource(index)}>Entfernen</button
|
|
||||||
>
|
>
|
||||||
</div>{/each}
|
<label class="wide-field"
|
||||||
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
|
>Anreißer<textarea
|
||||||
|
name="presentationDescription"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
bind:value={draft.presentationDescription}
|
||||||
|
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
|
||||||
|
>
|
||||||
|
<div class="presentation-preview-actions wide-field">
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => previewForm.requestSubmit()}
|
||||||
|
disabled={actionsBlocked || draft.presentationSourcePosition === null}
|
||||||
|
>Vorschau laden</button
|
||||||
|
><small
|
||||||
|
>{draft.presentationMetadataFetchedAtUtc
|
||||||
|
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
|
||||||
|
: 'Noch keine Metadaten geladen.'}</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
||||||
|
<PresentationDisplay
|
||||||
|
content={previewContent}
|
||||||
|
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationTitleManual"
|
||||||
|
value={draft.presentationTitleManual ? '1' : '0'}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationDescriptionManual"
|
||||||
|
value={draft.presentationDescriptionManual ? '1' : '0'}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationImageManual"
|
||||||
|
value={draft.presentationImageManual ? '1' : '0'}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||||
|
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
|
||||||
|
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationMetadataFetchedAtUtc"
|
||||||
|
value={draft.presentationMetadataFetchedAtUtc ?? ''}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
|
||||||
|
<div class="piece-subsection-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Private Dateien</p>
|
||||||
|
<h3>Material</h3>
|
||||||
|
</div>
|
||||||
|
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
|
||||||
|
</div>
|
||||||
|
{#if data.piece.media.length}<div class="media-grid">
|
||||||
|
{#each data.piece.media as media (media.id)}<figure>
|
||||||
|
<img src={`/media/${media.id}`} alt={media.altText} />
|
||||||
|
<figcaption>
|
||||||
|
<span>{media.altText || media.originalName}</span>
|
||||||
|
<form method="POST" action="?/deleteImage">
|
||||||
|
<input type="hidden" name="revision" value={revision} /><input
|
||||||
|
type="hidden"
|
||||||
|
name="mediaId"
|
||||||
|
value={media.id}
|
||||||
|
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
|
||||||
|
>Entfernen</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</figcaption>
|
||||||
|
</figure>{/each}
|
||||||
|
</div>{/if}
|
||||||
|
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
|
||||||
|
<input type="hidden" name="revision" value={revision} /><label
|
||||||
|
>Bild<input
|
||||||
|
name="image"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
required
|
||||||
|
/></label
|
||||||
|
><label
|
||||||
|
>Alternativtext<input
|
||||||
|
name="altText"
|
||||||
|
maxlength="300"
|
||||||
|
placeholder="Was ist auf dem Bild zu sehen?"
|
||||||
|
/></label
|
||||||
|
><button class="secondary-button" type="submit" disabled={actionsBlocked}
|
||||||
|
>Bild hochladen</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="editor-savebar">
|
{#snippet footer()}
|
||||||
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
|
<div class="piece-dialog-secondary-actions">
|
||||||
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
|
<form method="POST" action="?/duplicate">
|
||||||
>Jetzt speichern</button
|
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
|
||||||
>
|
</form>
|
||||||
</div>
|
<form method="POST" action="?/archive">
|
||||||
</form>
|
<input type="hidden" name="revision" value={revision} /><button
|
||||||
|
class="text-button danger-text"
|
||||||
|
type="submit"
|
||||||
|
aria-label="Piece archivieren"
|
||||||
|
disabled={actionsBlocked}>Archivieren</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="piece-dialog-primary-action">
|
||||||
|
<span
|
||||||
|
class="save-indicator"
|
||||||
|
class:problem={saveState === 'error' || saveState === 'conflict'}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite">{saveMessage}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
bind:this={saveButton}
|
||||||
|
class="primary-button"
|
||||||
|
type="submit"
|
||||||
|
form="piece-editor-form"
|
||||||
|
disabled={saving}>Jetzt speichern</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PieceEditorDialog>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
bind:this={previewForm}
|
bind:this={previewForm}
|
||||||
id="preview-load-form"
|
id="preview-load-form"
|
||||||
@@ -455,62 +599,4 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="revision" value={revision} />
|
<input type="hidden" name="revision" value={revision} />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="editor-section media-editor">
|
|
||||||
<div class="section-title">
|
|
||||||
<p class="eyebrow">Private Dateien</p>
|
|
||||||
<h2>Bilder</h2>
|
|
||||||
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
|
|
||||||
</div>
|
|
||||||
{#if data.piece.media.length}<div class="media-grid">
|
|
||||||
{#each data.piece.media as media (media.id)}<figure>
|
|
||||||
<img src={`/media/${media.id}`} alt={media.altText} />
|
|
||||||
<figcaption>
|
|
||||||
<span>{media.altText || media.originalName}</span>
|
|
||||||
<form method="POST" action="?/deleteImage">
|
|
||||||
<input type="hidden" name="revision" value={revision} /><input
|
|
||||||
type="hidden"
|
|
||||||
name="mediaId"
|
|
||||||
value={media.id}
|
|
||||||
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
|
|
||||||
>Entfernen</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
</figcaption>
|
|
||||||
</figure>{/each}
|
|
||||||
</div>{/if}
|
|
||||||
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
|
|
||||||
<input type="hidden" name="revision" value={revision} /><label
|
|
||||||
>Bild<input
|
|
||||||
name="image"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
required
|
|
||||||
/></label
|
|
||||||
><label
|
|
||||||
>Alternativtext<input
|
|
||||||
name="altText"
|
|
||||||
maxlength="300"
|
|
||||||
placeholder="Was ist auf dem Bild zu sehen?"
|
|
||||||
/></label
|
|
||||||
><button class="secondary-button" type="submit" disabled={actionsBlocked}
|
|
||||||
>Bild hochladen</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-actions">
|
|
||||||
<form method="POST" action="?/duplicate">
|
|
||||||
<button class="secondary-button" type="submit" disabled={actionsBlocked}
|
|
||||||
>Piece duplizieren</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
<form method="POST" action="?/archive">
|
|
||||||
<input type="hidden" name="revision" value={revision} /><button
|
|
||||||
class="secondary-button danger-text"
|
|
||||||
type="submit"
|
|
||||||
disabled={actionsBlocked}>Piece archivieren</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
+96
-4
@@ -30,6 +30,16 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
|
|||||||
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
|
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEditorialField(page: Page, heading: string) {
|
||||||
|
if (heading.includes('Recherche')) {
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
|
} else if (heading.includes('Präsentation')) {
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function login(page: Page, email = adminEmail, password = adminPassword) {
|
async function login(page: Page, email = adminEmail, password = adminPassword) {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('E-Mail').fill(email);
|
await page.getByLabel('E-Mail').fill(email);
|
||||||
@@ -1014,16 +1024,26 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
await expect(page).toHaveURL(/\/fundus$/);
|
await expect(page).toHaveURL(/\/fundus$/);
|
||||||
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
|
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
|
||||||
await expect(page).toHaveURL(pieceUrl);
|
await expect(page).toHaveURL(pieceUrl);
|
||||||
|
await page.getByText('Status, Dauer und Einordnung').click();
|
||||||
await page
|
await page
|
||||||
.getByLabel('Kurzbeschreibung')
|
.getByLabel('Kurzbeschreibung')
|
||||||
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
|
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
|
||||||
await page.getByLabel('Status').selectOption('ready');
|
await page.getByLabel('Status').selectOption('ready');
|
||||||
await page.getByLabel('Dauer in Minuten').fill('10.25');
|
await page.getByLabel('Dauer in Minuten').fill('10.25');
|
||||||
await page.getByLabel('Gaming').check();
|
await page.getByLabel('Gaming').check();
|
||||||
|
await openEditorialField(page, 'Private Recherche');
|
||||||
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
|
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
|
||||||
|
await openEditorialField(page, 'Moderationsstichpunkte');
|
||||||
|
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
|
||||||
|
await openEditorialField(page, 'Private Recherche');
|
||||||
|
await expect(page.getByLabel('Private Recherche')).toHaveValue(
|
||||||
|
'Nur intern: **Hintergrund prüfen**.'
|
||||||
|
);
|
||||||
|
await openEditorialField(page, 'Präsentationstext');
|
||||||
await page
|
await page
|
||||||
.getByLabel('Präsentationstext')
|
.getByLabel('Öffentlicher Text')
|
||||||
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
|
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
|
||||||
|
await openEditorialField(page, 'Private Recherche');
|
||||||
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
|
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
|
||||||
const source = page.locator('.source-row').last();
|
const source = page.locator('.source-row').last();
|
||||||
await source.getByLabel('Name').fill('Originalbeitrag');
|
await source.getByLabel('Name').fill('Originalbeitrag');
|
||||||
@@ -1032,11 +1052,14 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
await page.waitForTimeout(1_200);
|
await page.waitForTimeout(1_200);
|
||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
|
||||||
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
|
await openEditorialField(page, 'Präsentationstext');
|
||||||
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
|
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
|
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
|
||||||
|
await page.getByText('Status, Dauer und Einordnung').click();
|
||||||
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
|
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
|
||||||
|
await openEditorialField(page, 'Private Recherche');
|
||||||
await expect(page.getByLabel('Private Recherche')).toHaveValue(
|
await expect(page.getByLabel('Private Recherche')).toHaveValue(
|
||||||
'Nur intern: **Hintergrund prüfen**.'
|
'Nur intern: **Hintergrund prüfen**.'
|
||||||
);
|
);
|
||||||
@@ -1067,6 +1090,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
await concurrent.goto(pieceUrl);
|
await concurrent.goto(pieceUrl);
|
||||||
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
|
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
|
||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
await concurrent.getByText('Status, Dauer und Einordnung').click();
|
||||||
await concurrent
|
await concurrent
|
||||||
.getByLabel('Kurzbeschreibung')
|
.getByLabel('Kurzbeschreibung')
|
||||||
.fill('Diese veraltete Änderung darf nicht gewinnen.');
|
.fill('Diese veraltete Änderung darf nicht gewinnen.');
|
||||||
@@ -1075,6 +1099,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
});
|
});
|
||||||
await concurrent.reload();
|
await concurrent.reload();
|
||||||
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
|
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
|
||||||
|
await concurrent.getByText('Status, Dauer und Einordnung').click();
|
||||||
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
|
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
|
||||||
'Diese veraltete Änderung darf nicht gewinnen.'
|
'Diese veraltete Änderung darf nicht gewinnen.'
|
||||||
);
|
);
|
||||||
@@ -1099,9 +1124,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
'Kopie von E2E Fundstück, extern aktualisiert'
|
'Kopie von E2E Fundstück, extern aktualisiert'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung');
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
|
||||||
|
timeout: 10_000
|
||||||
|
});
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
await page.getByRole('button', { name: 'Jetzt speichern' }).click();
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await setTheme(page, 'dark');
|
await setTheme(page, 'dark');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
|
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
|
||||||
@@ -1109,23 +1145,77 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a quickly captured video can be written and previewed before classification', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: '<!doctype html><title>YouTube Test</title>'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.goto('/eingang');
|
||||||
|
await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück');
|
||||||
|
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
|
||||||
|
await page.getByRole('button', { name: 'Im Eingang ablegen' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/);
|
||||||
|
await page.locator('.inbox-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
|
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ'
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Status')).toBeHidden();
|
||||||
|
|
||||||
|
await openEditorialField(page, 'Private Recherche');
|
||||||
|
await page
|
||||||
|
.getByLabel('Private Recherche')
|
||||||
|
.fill('Warum funktioniert der Einstieg so unmittelbar?');
|
||||||
|
await openEditorialField(page, 'Moderationsstichpunkte');
|
||||||
|
await page
|
||||||
|
.getByLabel('Moderationsstichpunkte')
|
||||||
|
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
|
await page.getByLabel('Video').check();
|
||||||
|
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
|
||||||
|
const previewPromise = page.waitForEvent('popup');
|
||||||
|
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
|
||||||
|
const preview = await previewPromise;
|
||||||
|
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
|
||||||
|
);
|
||||||
|
await preview.close();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
|
await expect(page.getByLabel('Video')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
|
await expect(page.getByLabel('Private Recherche')).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
|
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
|
||||||
page
|
page
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await page.goto('/fundus/piece-console');
|
await page.goto('/fundus/piece-console');
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
await page.getByLabel('Linkvorschau').check();
|
await page.getByLabel('Linkvorschau').check();
|
||||||
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
|
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
|
||||||
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
|
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau');
|
||||||
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
|
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
|
||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
|
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
|
||||||
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
|
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
|
||||||
'Manuell korrigierte Linkvorschau'
|
'Manuell korrigierte Linkvorschau'
|
||||||
);
|
);
|
||||||
|
|
||||||
const previewPromise = page.waitForEvent('popup');
|
const previewPromise = page.waitForEvent('popup');
|
||||||
await page.getByRole('link', { name: 'Präsentation testen' }).click();
|
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
|
||||||
const preview = await previewPromise;
|
const preview = await previewPromise;
|
||||||
await preview.waitForLoadState('networkidle');
|
await preview.waitForLoadState('networkidle');
|
||||||
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
|
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
|
||||||
@@ -1146,8 +1236,10 @@ test('link preview uses one existing source, persists manual fields and isolates
|
|||||||
});
|
});
|
||||||
await preview.close();
|
await preview.close();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
|
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
|
||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
const blockedPreview = page.waitForResponse((response) =>
|
const blockedPreview = page.waitForResponse((response) =>
|
||||||
response.url().includes('?/loadPreview')
|
response.url().includes('?/loadPreview')
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user