Release 0.3.0
This commit is contained in:
1 parent
08e8875cb5
commit
7590be66a5
31 files changed
+3270
-151
No files matched your search
@@ -1,5 +1,82 @@
|
||||
import { getPieces } from '#lib/server/previews.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import {
|
||||
createPiece,
|
||||
duplicatePiece,
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
pieceTemplates,
|
||||
setPieceArchived,
|
||||
type PieceTemplateId
|
||||
} from '#lib/server/content.js';
|
||||
|
||||
export function load() {
|
||||
return { pieces: getPieces() };
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
requireUser(locals.user);
|
||||
const filters = {
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
formatId: url.searchParams.get('format') ?? '',
|
||||
topicId: url.searchParams.get('topic') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? '',
|
||||
status: url.searchParams.get('status') ?? ''
|
||||
};
|
||||
return {
|
||||
pieces: listPieces({
|
||||
statuses:
|
||||
filters.status === 'ready'
|
||||
? ['ready']
|
||||
: filters.status === 'preparing'
|
||||
? ['preparing']
|
||||
: ['preparing', 'ready'],
|
||||
query: filters.query,
|
||||
formatId: filters.formatId,
|
||||
topicId: filters.topicId,
|
||||
categoryId: filters.categoryId
|
||||
}),
|
||||
taxonomy: getTaxonomy(),
|
||||
templates: pieceTemplates,
|
||||
filters
|
||||
};
|
||||
};
|
||||
|
||||
function actionError(cause: unknown) {
|
||||
const code = cause instanceof Error ? cause.message : '';
|
||||
if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.';
|
||||
if (code === 'NOT_FOUND') return 'Das Piece wurde nicht gefunden.';
|
||||
return 'Die Aktion konnte nicht ausgeführt werden.';
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
create: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
let id: string;
|
||||
try {
|
||||
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId);
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'create', error: actionError(cause) });
|
||||
}
|
||||
redirect(303, `/fundus/${id}`);
|
||||
},
|
||||
duplicate: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
let id: string;
|
||||
try {
|
||||
id = await duplicatePiece(String(data.get('pieceId') || ''));
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'duplicate', error: actionError(cause) });
|
||||
}
|
||||
redirect(303, `/fundus/${id}`);
|
||||
},
|
||||
archive: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
|
||||
return { intent: 'archive', success: 'Piece archiviert.' };
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'archive', error: actionError(cause) });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
+108
-56
@@ -1,19 +1,9 @@
|
||||
<script lang="ts">
|
||||
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { formatClock } from '#lib/format.js';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let query = $state('');
|
||||
let filter = $state<'all' | 'ready' | 'preparing'>('all');
|
||||
let filteredPieces = $derived(
|
||||
data.pieces.filter((piece) => {
|
||||
const text =
|
||||
`${piece.title} ${piece.summary} ${piece.format} ${piece.topics.join(' ')}`.toLowerCase();
|
||||
return text.includes(query.toLowerCase()) && (filter === 'all' || piece.status === filter);
|
||||
})
|
||||
);
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
const statusLabel = {
|
||||
ready: 'Sendefertig',
|
||||
preparing: 'In Vorbereitung',
|
||||
@@ -30,57 +20,125 @@
|
||||
<h1>Fundus</h1>
|
||||
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
|
||||
</div>
|
||||
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
|
||||
<div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}>
|
||||
<strong>{data.pieces.length}</strong><span>Inhalte</span>
|
||||
</div>
|
||||
</header>
|
||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
|
||||
|
||||
<PreviewNotice area="Fundus" />
|
||||
|
||||
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern">
|
||||
<label class="search-field">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<span class="sr-only">Fundus durchsuchen</span>
|
||||
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" />
|
||||
</label>
|
||||
<div class="filter-tabs" aria-label="Status filtern">
|
||||
<button class:active={filter === 'all'} type="button" onclick={() => (filter = 'all')}
|
||||
>Alle</button
|
||||
>
|
||||
<button class:active={filter === 'ready'} type="button" onclick={() => (filter = 'ready')}
|
||||
>Sendefertig</button
|
||||
>
|
||||
<button
|
||||
class:active={filter === 'preparing'}
|
||||
type="button"
|
||||
onclick={() => (filter = 'preparing')}>In Arbeit</button
|
||||
>
|
||||
<details class="template-picker">
|
||||
<summary>Neues Piece anlegen</summary>
|
||||
<div class="template-grid">
|
||||
{#each data.templates as template (template.id)}
|
||||
<form method="POST" action="?/create">
|
||||
<input type="hidden" name="template" value={template.id} />
|
||||
<button type="submit"
|
||||
><strong>{template.name}</strong><span>{template.description}</span></button
|
||||
>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{#if filteredPieces.length > 0}
|
||||
<form class="library-filters" method="GET" aria-label="Fundus durchsuchen und filtern">
|
||||
<label class="search-field"
|
||||
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
|
||||
name="q"
|
||||
type="search"
|
||||
value={data.filters.query}
|
||||
placeholder="Titel, Quelle oder Thema suchen"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><span>Status</span><select name="status"
|
||||
><option value="">Alle</option><option
|
||||
value="ready"
|
||||
selected={data.filters.status === 'ready'}>Sendefertig</option
|
||||
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Format</span><select name="format"
|
||||
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.formatId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Thema</span><select name="topic"
|
||||
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.topicId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Abschnitt</span><select name="category"
|
||||
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.categoryId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Anwenden</button>
|
||||
<a class="text-link" href={resolve('/fundus')}>Zurücksetzen</a>
|
||||
</form>
|
||||
|
||||
{#if data.pieces.length}
|
||||
<div class="piece-grid">
|
||||
{#each filteredPieces as piece (piece.id)}
|
||||
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
|
||||
{#each data.pieces as piece (piece.id)}
|
||||
<article
|
||||
class="piece-card"
|
||||
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
|
||||
>
|
||||
{#if piece.mediaId}<img
|
||||
class="piece-thumbnail"
|
||||
src={`/media/${piece.mediaId}`}
|
||||
alt=""
|
||||
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"
|
||||
>S</span
|
||||
>{/if}
|
||||
<div class="piece-primary">
|
||||
<h2>{piece.title}</h2>
|
||||
<p>{piece.summary}</p>
|
||||
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
|
||||
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
|
||||
</div>
|
||||
<div class="piece-classification">
|
||||
<span class="category-label"><i></i>{piece.category}</span>
|
||||
<span class="category-label"
|
||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||
>
|
||||
<div class="piece-tags">
|
||||
<span>{piece.format}</span>
|
||||
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
|
||||
<span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span
|
||||
>{topic.name}</span
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="piece-state">
|
||||
<span class:ready={piece.status === 'ready'} class="status-label"
|
||||
>{statusLabel[piece.status]}</span
|
||||
><span class="piece-facts"
|
||||
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span
|
||||
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
||||
></span
|
||||
>
|
||||
<span class="piece-facts">
|
||||
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
|
||||
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="piece-actions">
|
||||
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
|
||||
<form method="POST" action="?/duplicate">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><button
|
||||
class="text-button"
|
||||
type="submit">Duplizieren</button
|
||||
>
|
||||
</form>
|
||||
<form method="POST" action="?/archive">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={piece.revision}
|
||||
/><button class="text-button danger-text" type="submit">Archivieren</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
@@ -88,15 +146,9 @@
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<h2>Kein passendes Stück</h2>
|
||||
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
query = '';
|
||||
filter = 'all';
|
||||
}}>Filter zurücksetzen</button
|
||||
>
|
||||
<h2>Kein passendes Piece</h2>
|
||||
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
|
||||
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import {
|
||||
deletePieceImage,
|
||||
duplicatePiece,
|
||||
getPiece,
|
||||
getTaxonomy,
|
||||
savePiece,
|
||||
setPieceArchived,
|
||||
uploadPieceImage,
|
||||
type EditablePieceStatus,
|
||||
type PieceInput,
|
||||
type PieceSourceInput
|
||||
} from '#lib/server/content.js';
|
||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||
|
||||
function renderedFields(
|
||||
piece: Pick<
|
||||
PieceInput,
|
||||
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
|
||||
>
|
||||
) {
|
||||
return {
|
||||
intro: renderMarkdown(piece.intro),
|
||||
research: renderMarkdown(piece.researchText),
|
||||
moderation: renderMarkdown(piece.moderationNotes),
|
||||
transition: renderMarkdown(piece.transitionText),
|
||||
presentation: renderMarkdown(piece.presentationText)
|
||||
};
|
||||
}
|
||||
|
||||
function text(data: FormData, name: string) {
|
||||
return String(data.get(name) || '');
|
||||
}
|
||||
|
||||
function pieceInput(data: FormData): PieceInput {
|
||||
const labels = data.getAll('sourceLabel').map(String);
|
||||
const urls = data.getAll('sourceUrl').map(String);
|
||||
const timecodes = data.getAll('sourceTimecode').map(String);
|
||||
const sources: PieceSourceInput[] = [];
|
||||
for (let index = 0; index < Math.max(labels.length, urls.length, timecodes.length); index += 1) {
|
||||
const label = labels[index]?.trim() ?? '';
|
||||
const url = urls[index]?.trim() ?? '';
|
||||
const timecode = timecodes[index]?.trim() ?? '';
|
||||
if (!label && !url && !timecode) continue;
|
||||
sources.push({
|
||||
label,
|
||||
url,
|
||||
timecodeSeconds: timecode ? Number(timecode) : null
|
||||
});
|
||||
}
|
||||
return {
|
||||
title: text(data, 'title'),
|
||||
summary: text(data, 'summary'),
|
||||
formatId: text(data, 'formatId') || null,
|
||||
status: text(data, 'status') as EditablePieceStatus,
|
||||
durationSeconds: Number(data.get('durationSeconds')),
|
||||
intro: text(data, 'intro'),
|
||||
researchText: text(data, 'researchText'),
|
||||
moderationNotes: text(data, 'moderationNotes'),
|
||||
transitionText: text(data, 'transitionText'),
|
||||
presentationText: text(data, 'presentationText'),
|
||||
topicIds: data.getAll('topicIds').map(String),
|
||||
categoryIds: data.getAll('categoryIds').map(String),
|
||||
sources
|
||||
};
|
||||
}
|
||||
|
||||
function saveError(cause: unknown) {
|
||||
const code = cause instanceof Error ? cause.message : '';
|
||||
if (code === 'CONFLICT')
|
||||
return {
|
||||
status: 409,
|
||||
error: 'Eine neuere Änderung liegt vor. Deine Eingaben wurden nicht überschrieben.',
|
||||
conflict: true
|
||||
};
|
||||
if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' };
|
||||
if (code === 'INVALID_DURATION')
|
||||
return { status: 400, error: 'Die Dauer muss zwischen 0 und 86.400 Sekunden liegen.' };
|
||||
if (code === 'INVALID_SOURCES')
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.'
|
||||
};
|
||||
if (code.startsWith('INVALID_'))
|
||||
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
|
||||
return {
|
||||
status: 500,
|
||||
error: 'Speichern fehlgeschlagen. Deine Eingaben bleiben im Formular erhalten.'
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals, params }) => {
|
||||
requireUser(locals.user);
|
||||
const piece = getPiece(params.id);
|
||||
if (!piece) error(404, 'Piece nicht gefunden');
|
||||
if (piece.status === 'archived') redirect(303, '/archiv');
|
||||
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
save: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const input = pieceInput(data);
|
||||
try {
|
||||
const saved = savePiece(params.id, Number(data.get('revision')), input);
|
||||
return { intent: 'save', saved: true, ...saved, rendered: renderedFields(input) };
|
||||
} catch (cause) {
|
||||
const problem = saveError(cause);
|
||||
return fail(problem.status, { intent: 'save', ...problem });
|
||||
}
|
||||
},
|
||||
upload: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const image = data.get('image');
|
||||
if (!(image instanceof File))
|
||||
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
|
||||
try {
|
||||
await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText'));
|
||||
} catch (cause) {
|
||||
const code = cause instanceof Error ? cause.message : '';
|
||||
if (code === 'CONFLICT')
|
||||
return fail(409, {
|
||||
intent: 'upload',
|
||||
error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.'
|
||||
});
|
||||
if (code === 'INVALID_IMAGE_SIZE')
|
||||
return fail(400, {
|
||||
intent: 'upload',
|
||||
error: 'Das Bild darf höchstens 5 MB groß und nicht leer sein.'
|
||||
});
|
||||
if (code === 'INVALID_IMAGE_TYPE')
|
||||
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
|
||||
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
|
||||
}
|
||||
redirect(303, `/fundus/${params.id}`);
|
||||
},
|
||||
deleteImage: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
await deletePieceImage(params.id, text(data, 'mediaId'), Number(data.get('revision')));
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
|
||||
}
|
||||
redirect(303, `/fundus/${params.id}`);
|
||||
},
|
||||
duplicate: async ({ locals, params }) => {
|
||||
requireUser(locals.user);
|
||||
let id: string;
|
||||
try {
|
||||
id = await duplicatePiece(params.id);
|
||||
} catch {
|
||||
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
|
||||
}
|
||||
redirect(303, `/fundus/${id}`);
|
||||
},
|
||||
archive: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceArchived(params.id, Number(data.get('revision')), true);
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'archive', error: saveError(cause).error });
|
||||
}
|
||||
redirect(303, '/archiv');
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,363 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let draft = $state(untrack(() => ({ ...data.piece })));
|
||||
let revision = $state(untrack(() => data.piece.revision));
|
||||
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||
let saveMessage = $state('Alle Änderungen gespeichert');
|
||||
let editorForm: HTMLFormElement;
|
||||
let saveButton: HTMLButtonElement;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let generation = 0;
|
||||
let saving = $state(false);
|
||||
let actionsBlocked = $derived(
|
||||
saveState === 'dirty' ||
|
||||
saveState === 'saving' ||
|
||||
saveState === 'error' ||
|
||||
saveState === 'conflict'
|
||||
);
|
||||
let pendingHref: string | null = null;
|
||||
let allowNavigation = false;
|
||||
|
||||
onDestroy(() => clearTimeout(timer));
|
||||
beforeNavigate((navigation) => {
|
||||
if (allowNavigation) return;
|
||||
if (saveState === 'error' || saveState === 'conflict') {
|
||||
if (!confirm('Ungespeicherte Änderungen verwerfen und Seite verlassen?')) navigation.cancel();
|
||||
return;
|
||||
}
|
||||
if (saveState !== 'dirty' && saveState !== 'saving') return;
|
||||
navigation.cancel();
|
||||
if (navigation.willUnload) return;
|
||||
pendingHref = navigation.to?.url.href ?? null;
|
||||
if (!saving) submitSave();
|
||||
});
|
||||
|
||||
function scheduleSave() {
|
||||
generation += 1;
|
||||
saveState = 'dirty';
|
||||
saveMessage = 'Ungespeicherte Änderungen';
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(submitSave, 900);
|
||||
}
|
||||
|
||||
function submitSave() {
|
||||
if (saving) return;
|
||||
editorForm.requestSubmit(saveButton);
|
||||
}
|
||||
|
||||
function addSource() {
|
||||
sources.push({ label: '', url: '', timecodeSeconds: null });
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function removeSource(index: number) {
|
||||
sources.splice(index, 1);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
const enhanceSave: SubmitFunction = () => {
|
||||
const submittedGeneration = generation;
|
||||
saving = true;
|
||||
saveState = 'saving';
|
||||
saveMessage = 'Speichert …';
|
||||
return async ({ result }) => {
|
||||
saving = false;
|
||||
if (result.type === 'success') {
|
||||
const response = result.data as { revision: number; rendered: typeof rendered };
|
||||
revision = response.revision;
|
||||
rendered = response.rendered;
|
||||
if (generation === submittedGeneration) {
|
||||
saveState = 'saved';
|
||||
saveMessage = 'Gespeichert';
|
||||
if (pendingHref) {
|
||||
const target = pendingHref;
|
||||
pendingHref = null;
|
||||
allowNavigation = true;
|
||||
// The target comes from SvelteKit's own NavigationTarget, never from user input.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
await goto(new URL(target), { invalidateAll: true });
|
||||
}
|
||||
} else {
|
||||
saveState = 'dirty';
|
||||
saveMessage = 'Weitere Änderungen warten';
|
||||
timer = setTimeout(submitSave, 250);
|
||||
}
|
||||
} else if (result.type === 'failure') {
|
||||
const response = result.data as { error?: string; conflict?: boolean };
|
||||
saveState = response.conflict ? 'conflict' : 'error';
|
||||
saveMessage = response.error ?? 'Speichern fehlgeschlagen.';
|
||||
} else {
|
||||
saveState = 'error';
|
||||
saveMessage = 'Speichern fehlgeschlagen.';
|
||||
}
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
|
||||
|
||||
<div class="page-wrap editor-page">
|
||||
<header class="editor-header">
|
||||
<div>
|
||||
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
|
||||
<h1>{draft.title}</h1>
|
||||
</div>
|
||||
<div
|
||||
class:problem={saveState === 'error' || saveState === 'conflict'}
|
||||
class="save-indicator"
|
||||
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} />
|
||||
<section class="editor-section editor-basics">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Grundlagen</p>
|
||||
<h2>Worum geht es?</h2>
|
||||
</div>
|
||||
<div class="editor-fields two-columns">
|
||||
<label class="wide-field"
|
||||
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
|
||||
>
|
||||
<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
|
||||
>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 Sekunden<input
|
||||
name="durationSeconds"
|
||||
type="number"
|
||||
min="0"
|
||||
max="86400"
|
||||
step="1"
|
||||
bind:value={draft.durationSeconds}
|
||||
/></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">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Einordnung</p>
|
||||
<h2>Themen und Abschnitte</h2>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="editor-section markdown-section">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Redaktion</p>
|
||||
<h2>Getrennte Arbeitsbereiche</h2>
|
||||
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
|
||||
</div>
|
||||
<div class="markdown-grid">
|
||||
<label
|
||||
><span>Einleitung</span><textarea
|
||||
name="intro"
|
||||
rows="7"
|
||||
maxlength="50000"
|
||||
bind:value={draft.intro}></textarea>
|
||||
<MarkdownPreview html={rendered.intro} /></label
|
||||
>
|
||||
<label class="private-field"
|
||||
><span>Private Recherche</span><textarea
|
||||
name="researchText"
|
||||
rows="7"
|
||||
maxlength="50000"
|
||||
bind:value={draft.researchText}></textarea>
|
||||
<MarkdownPreview html={rendered.research} /></label
|
||||
>
|
||||
<label
|
||||
><span>Moderationsstichpunkte</span><textarea
|
||||
name="moderationNotes"
|
||||
rows="7"
|
||||
maxlength="50000"
|
||||
bind:value={draft.moderationNotes}></textarea>
|
||||
<MarkdownPreview html={rendered.moderation} /></label
|
||||
>
|
||||
<label
|
||||
><span>Überleitung</span><textarea
|
||||
name="transitionText"
|
||||
rows="7"
|
||||
maxlength="50000"
|
||||
bind:value={draft.transitionText}></textarea>
|
||||
<MarkdownPreview html={rendered.transition} /></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
><span>Präsentationstext</span><textarea
|
||||
name="presentationText"
|
||||
rows="7"
|
||||
maxlength="50000"
|
||||
bind:value={draft.presentationText}></textarea>
|
||||
<MarkdownPreview html={rendered.presentation} /></label
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="editor-section source-editor">
|
||||
<div class="section-title split-section-title">
|
||||
<div>
|
||||
<p class="eyebrow">Belege</p>
|
||||
<h2>Quellen</h2>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
|
||||
>
|
||||
</div>
|
||||
{#if sources.length}<div class="source-list">
|
||||
{#each sources as source, index (index)}<div class="source-row">
|
||||
<label
|
||||
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
|
||||
><label
|
||||
>URL<input
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
bind:value={source.url}
|
||||
/></label
|
||||
><label
|
||||
>Zeitmarke in Sekunden<input
|
||||
name="sourceTimecode"
|
||||
type="number"
|
||||
min="0"
|
||||
max="86400"
|
||||
bind:value={source.timecodeSeconds}
|
||||
/></label
|
||||
><button
|
||||
class="text-button danger-text"
|
||||
type="button"
|
||||
onclick={() => removeSource(index)}>Entfernen</button
|
||||
>
|
||||
</div>{/each}
|
||||
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
|
||||
</section>
|
||||
|
||||
<div class="editor-savebar">
|
||||
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
|
||||
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
|
||||
>Jetzt speichern</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
Reference in new issue
Block a user