Moves Status + Persons sections into a shared component so both GeschichteEditor (STORY) and the upcoming JourneyEditor (JOURNEY) can use the same sidebar without duplicating markup. Adds <details> mobile collapsibles with 44px summary hit areas. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
280 lines
9.4 KiB
Svelte
280 lines
9.4 KiB
Svelte
<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 GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
|
|
|
|
type Geschichte = components['schemas']['Geschichte'];
|
|
type Person = components['schemas']['Person'];
|
|
|
|
interface Props {
|
|
geschichte?: Geschichte | null;
|
|
initialPersons?: Person[];
|
|
onSubmit: (payload: {
|
|
title: string;
|
|
body: string;
|
|
status: 'DRAFT' | 'PUBLISHED';
|
|
personIds: string[];
|
|
}) => Promise<void>;
|
|
submitting?: boolean;
|
|
}
|
|
|
|
let { geschichte = null, initialPersons = [], 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 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)
|
|
});
|
|
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 -->
|
|
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
|
|
</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>
|