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:
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user