feat(geschichten): add GeschichteEditor with Tiptap toolbar

Tiptap StarterKit configured for B/I/¶/H2/H3/UL/OL/history; code, codeBlock,
blockquote, strike, horizontalRule and hardBreak disabled to keep output
matching the backend HTML allow-list. Two-column responsive layout with the
editor body on the left and Personen / Dokumente / Status sections in the
sidebar. Sticky save bar adapts to DRAFT vs PUBLISHED state. Title-required
guard with inline error and beforeNavigate dirty-state guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:49:10 +02:00
parent b381b2078a
commit ab3e633a0c

View File

@@ -0,0 +1,329 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from './PersonMultiSelect.svelte';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
type Geschichte = components['schemas']['Geschichte'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
initialPersons?: Person[];
initialDocuments?: Document[];
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
// `geschichte` to reset (consistent with how form components in this codebase
// behave — see DocumentEdit page).
let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
);
let dirty = $state(false);
let titleTouched = $state(false);
const titleEmpty = $derived(title.trim().length === 0);
const isDraft = $derived(status === 'DRAFT');
const showTitleError = $derived(titleEmpty && titleTouched);
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
let toolbarVersion = $state(0);
onMount(() => {
editor = new Editor({
element: editorEl,
content: body,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
code: false,
codeBlock: false,
blockquote: false,
strike: false,
horizontalRule: false,
hardBreak: false
})
],
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.geschichte_editor_body_placeholder(),
class:
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
}
},
onUpdate({ editor: ed }) {
body = ed.getHTML();
dirty = true;
},
onSelectionUpdate() {
toolbarVersion++;
},
onTransaction() {
toolbarVersion++;
}
});
});
onDestroy(() => {
editor?.destroy();
});
beforeNavigate(({ cancel }) => {
if (dirty && !submitting) {
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
if (!ok) cancel();
}
});
function handleTitleBlur() {
titleTouched = true;
}
function handleTitleInput() {
dirty = true;
}
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
});
dirty = false;
}
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
void toolbarVersion;
return editor?.isActive(name, attrs) ?? false;
}
function exec(action: () => void) {
if (!editor) return;
action();
editor.commands.focus();
}
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
type="text"
bind:value={title}
oninput={handleTitleInput}
onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'title-error' : undefined}
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if showTitleError}
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Toolbar -->
<div
role="toolbar"
aria-label="Formatierung"
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
aria-label={m.geschichte_editor_toolbar_bold()}
aria-pressed={isActive('bold')}
title={m.geschichte_editor_toolbar_bold()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
B
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
aria-label={m.geschichte_editor_toolbar_italic()}
aria-pressed={isActive('italic')}
title={m.geschichte_editor_toolbar_italic()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
I
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
aria-label={m.geschichte_editor_toolbar_h2()}
aria-pressed={isActive('heading', { level: 2 })}
title={m.geschichte_editor_toolbar_h2()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H2
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
aria-label={m.geschichte_editor_toolbar_h3()}
aria-pressed={isActive('heading', { level: 3 })}
title={m.geschichte_editor_toolbar_h3()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H3
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
aria-label={m.geschichte_editor_toolbar_ul()}
aria-pressed={isActive('bulletList')}
title={m.geschichte_editor_toolbar_ul()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
aria-label={m.geschichte_editor_toolbar_ol()}
aria-pressed={isActive('orderedList')}
title={m.geschichte_editor_toolbar_ol()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
1.
</button>
</div>
<!-- Editor surface -->
<div
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
>
<div bind:this={editorEl} class="min-h-[260px]"></div>
</div>
</div>
<!-- Sidebar -->
<aside class="flex flex-col gap-6">
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft
? m.geschichte_editor_status_draft()
: m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_save_hint_draft()
: m.geschichte_editor_save_hint_published()}
</p>
<div class="flex gap-2">
{#if isDraft}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save_draft()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_publish()}
</button>
{:else}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_unpublish()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save()}
</button>
{/if}
</div>
</div>