Files
familienarchiv/frontend/src/lib/geschichte/JourneyEditor.svelte

342 lines
12 KiB
Svelte

<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
import GeschichteSidebar from './GeschichteSidebar.svelte';
import JourneyItemRow from './JourneyItemRow.svelte';
import JourneyAddBar from './JourneyAddBar.svelte';
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
type Person = components['schemas']['Person'];
interface Props {
geschichte: GeschichteView;
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let { geschichte, onSubmit, submitting = false }: Props = $props();
const unsaved = createUnsavedWarning();
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) : []);
let items: JourneyItemView[] = $state(
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
);
let titleTouched = $state(false);
let mutationError = $state('');
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleAnnounceReset() {
if (announceTimer) clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveAnnounce = '';
announceTimer = null;
}, 500);
}
const titleEmpty = $derived(title.trim().length === 0);
const showTitleError = $derived(titleEmpty && titleTouched);
const isDraft = $derived(status === 'DRAFT');
const alreadyAddedIds = $derived(
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
const canPublish = $derived(items.length > 0 && !titleEmpty);
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
let listEl: HTMLElement | null = $state(null);
const dragDrop = createBlockDragDrop<JourneyItemView>({
getSortedBlocks: () => items,
onReorder: handleReorder
});
$effect(() => {
dragDrop.setListElement(listEl);
});
async function handleReorder(itemIds: string[]) {
const prev = [...items];
items = itemIds.map((id) => items.find((i) => i.id === id)!);
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds })
});
if (!res.ok) throw new Error('reorder failed');
const updated: JourneyItemView[] = await res.json();
items = updated.sort((a, b) => a.position - b.position);
} catch {
items = prev;
mutationError = m.journey_mutation_error_reload();
}
}
async function handleAddDocument(doc: DocumentOption) {
const prev = [...items];
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: doc.id })
});
if (!res.ok) throw new Error('add document failed');
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
} catch {
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
mutationError = m.journey_mutation_error_reload();
}
}
async function handleAddInterlude(text: string) {
const prev = [...items];
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: text })
});
if (!res.ok) throw new Error('add interlude failed');
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
} catch {
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
mutationError = m.journey_mutation_error_reload();
}
}
async function handleRemove(itemId: string) {
const prev = [...items];
items = items.filter((i) => i.id !== itemId);
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('delete failed');
} catch {
items = prev;
mutationError = m.journey_mutation_error_reload();
}
}
async function handleNotePatch(itemId: string, note: string | null) {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: note })
});
if (!res.ok) throw new Error('note patch failed');
const updated: JourneyItemView = await res.json();
items = items.map((i) => (i.id === itemId ? updated : i));
}
async function handleMoveUp(index: number) {
if (index === 0) return;
const ids = items.map((i) => i.id);
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
liveAnnounce = m.journey_item_moved({
position: index + 1,
total: items.length,
newPosition: index
});
await handleReorder(ids);
scheduleAnnounceReset();
}
async function handleMoveDown(index: number) {
if (index === items.length - 1) return;
const ids = items.map((i) => i.id);
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
liveAnnounce = m.journey_item_moved({
position: index + 1,
total: items.length,
newPosition: index + 2
});
await handleReorder(ids);
scheduleAnnounceReset();
}
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)
});
unsaved.clearOnSuccess();
}
</script>
<!-- Screen-reader live region for move announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
<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={() => unsaved.markDirty()}
onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'journey-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="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Intro textarea -->
<div>
<textarea
bind:value={body}
oninput={() => unsaved.markDirty()}
placeholder={m.journey_intro_placeholder()}
rows={3}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
</div>
<!-- Item list -->
{#if showPublishedEmptyWarning}
<p
class="rounded border border-amber-300 bg-amber-50 px-3 py-2 font-sans text-sm text-amber-800"
>
{m.journey_published_empty_warning()}
</p>
{/if}
{#if mutationError}
<p
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
role="alert"
>
{mutationError}
</p>
{/if}
<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={listEl}
onpointermove={(e) => dragDrop.handlePointerMove(e)}
onpointerup={() => dragDrop.handlePointerUp()}
class="flex flex-col gap-2"
>
{#if items.length === 0}
<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
{/if}
{#each items as item, i (item.id)}
<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
style={dragDrop.draggedBlockId === item.id
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
: ''}
>
{#if dragDrop.dropTargetIdx === i}
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
{/if}
<JourneyItemRow
item={item}
index={i}
total={items.length}
onMoveUp={() => handleMoveUp(i)}
onMoveDown={() => handleMoveDown(i)}
onRemove={() => handleRemove(item.id)}
onNotePatch={(note) => handleNotePatch(item.id, note)}
/>
</div>
{/each}
</div>
<JourneyAddBar
alreadyAddedIds={alreadyAddedIds}
onAddDocument={handleAddDocument}
onAddInterlude={handleAddInterlude}
/>
</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.journey_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 || !canPublish}
title={canPublish ? undefined : m.journey_publish_disabled_title()}
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>