feat(geschichte): StoryDocumentPanel — sidebar document management for stories (#795)

Sidebar-section-styled panel (p-4 card, mobile <details> accordion, no
inner scroll clamp) that lists a story's journey items in position order.
Add is pessimistic via POST /items; remove is optimistic with snapshot
rollback via DELETE /items/{id}; both through csrfFetch. Already-linked
documents are unselectable in the reused DocumentPickerDropdown (visible
label wired via inputId). Document-less items (ON DELETE SET NULL)
render as removable placeholder rows. 409 capacity/duplicate map to
story-worded messages, everything else through getErrorMessage(). Add/
remove are announced in a polite live region and focus moves to the
previous row's remove button (picker input when the list empties).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-11 12:35:32 +02:00
parent 48f2c67ffc
commit 4f0a660cb8
2 changed files with 505 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { getErrorMessage } from '$lib/shared/errors';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
geschichteId: string;
items?: JourneyItemView[];
}
let { geschichteId, items: initialItems = [] }: Props = $props();
const uid = $props.id();
const pickerInputId = `story-doc-picker-${uid}`;
// Initial-state snapshot — the panel owns the list after mount and updates
// it from API responses; the parent re-mounts to reset (same contract as
// GeschichteEditor/JourneyEditor).
// svelte-ignore state_referenced_locally
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
let errorMessage = $state('');
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
let sectionEl: HTMLElement | null = $state(null);
const alreadyAddedIds = $derived(
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
function announce(message: string) {
liveAnnounce = message;
if (announceTimer) clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveAnnounce = '';
announceTimer = null;
}, 500);
}
function itemTitle(item: JourneyItemView): string {
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
}
/** Maps a failed mutation to a user-facing message — story wording for the
* two journey-flavored 409s, whose generic messages say "Lesereise". */
async function failureMessage(res: Response): Promise<string> {
const code = (await res.json().catch(() => ({})))?.code;
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}
/** Pessimistic append — the list updates only with the server's response. */
async function handleAdd(doc: DocumentOption) {
errorMessage = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: doc.id })
});
if (!res.ok) {
errorMessage = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
} catch (e) {
console.error('Story document add failed', e);
errorMessage = m.journey_mutation_error_reload();
}
}
/** The removed row's button leaves the DOM — without this, focus drops to
* <body> and a keyboard user is teleported to page top. */
async function moveFocusAfterRemove(removedIdx: number) {
await tick();
if (items.length === 0) {
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
return;
}
const target = items[Math.max(removedIdx - 1, 0)];
sectionEl?.querySelector<HTMLElement>(`[data-item-id="${target.id}"] [data-remove-btn]`)?.focus();
}
/** Optimistic removal with snapshot-and-rollback. */
async function handleRemove(item: JourneyItemView) {
const idx = items.findIndex((i) => i.id === item.id);
const prev = [...items];
errorMessage = '';
items = items.filter((i) => i.id !== item.id);
await moveFocusAfterRemove(idx);
try {
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
method: 'DELETE'
});
if (!res.ok) {
items = prev;
errorMessage = await failureMessage(res);
return;
}
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
} catch (e) {
console.error('Story document remove failed', e);
items = prev;
errorMessage = m.journey_mutation_error_reload();
}
}
</script>
<!-- Screen-reader live region for add/remove confirmations -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_documents_heading()}
</summary>
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
<h2
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_documents_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
{#if errorMessage}
<p
role="alert"
class="mb-3 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
>
{errorMessage}
</p>
{/if}
{#if items.length === 0}
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
{:else}
<ul class="m-0 mb-3 flex list-none flex-col p-0">
{#each items as item (item.id)}
<li
data-item-id={item.id}
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
>
{#if item.document}
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{item.document.title}
</span>
{:else}
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
{m.geschichte_documents_deleted_placeholder()}
</span>
{/if}
<button
type="button"
data-remove-btn
onclick={() => handleRemove(item)}
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
{m.geschichte_documents_picker_label()}
</label>
<DocumentPickerDropdown
inputId={pickerInputId}
alreadyAddedIds={alreadyAddedIds}
placeholder={m.geschichte_documents_picker_placeholder()}
onSelect={handleAdd}
/>
</section>
</details>