Release 0.3.0
This commit is contained in:
1 parent
08e8875cb5
commit
7590be66a5
31 files changed
+3270
-151
No files matched your search
@@ -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