feat(geschichten): add /geschichten routes (index, detail, new, edit)

- /geschichten — published-stories index with filter pills + "+ Neue Geschichte"
  for BLOG_WRITERs; supports ?personId and ?documentId pre-filtering
- /geschichten/[id] — reader detail with sanitised {@html} body, person and
  document chip sections, BLOG_WRITER edit/delete with confirm dialog
- /geschichten/new — editor with optional ?personId and ?documentId pre-fill
  (silent ignore on unknown IDs to avoid leaking entity existence)
- /geschichten/[id]/edit — editor populated from existing story; BLOG_WRITE
  guard redirects readers to the detail page

All routes load via createApiClient(fetch) with !response.ok error handling
following the project pattern; PATCH/DELETE go through raw fetch which the
Vite dev proxy / Caddy production proxy authenticates via cookie.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:54:31 +02:00
parent 9e6efacbcb
commit fe1014a08a
8 changed files with 492 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/utils/stripHtml';
import { formatDate } from '$lib/utils/date';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false);
const filterName = $derived(data.personFilter?.displayName ?? '');
function clearFilter() {
const url = new URL(window.location.href);
url.searchParams.delete('personId');
url.searchParams.delete('documentId');
goto(url.pathname + url.search, { replaceState: true });
}
function pickPerson(personId: string) {
if (!personId) return;
const url = new URL(window.location.href);
url.searchParams.set('personId', personId);
url.searchParams.delete('documentId');
showPersonPicker = false;
goto(url.pathname + url.search);
}
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function publishedAt(g: { publishedAt?: string }): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
{#if data.canBlogWrite}
<a
href="/geschichten/new"
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"
>
{m.geschichten_new_button()}
</a>
{/if}
</header>
<!-- Filter pills -->
<div class="mb-6 flex flex-wrap items-center gap-2">
<button
type="button"
aria-pressed={!data.personFilter}
onclick={clearFilter}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
>
{m.geschichten_filter_all_pill()}
</button>
{#if data.personFilter}
<button
type="button"
aria-pressed="true"
onclick={clearFilter}
class="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
>
{filterName}
<span aria-hidden="true">×</span>
</button>
{:else}
<button
type="button"
aria-label={m.geschichten_filter_aria_label()}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
{/if}
</div>
{#if showPersonPicker}
<div class="mb-4">
<PersonTypeahead
name="filter-person"
label={m.geschichten_filter_choose_person()}
compact
onchange={pickPerson}
/>
</div>
{/if}
<!-- Card list -->
{#if data.geschichten.length === 0}
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
{#if data.personFilter}
{m.geschichten_empty_for_person({ name: filterName })}
{:else}
{m.geschichten_empty_no_filter()}
{/if}
</div>
{:else}
<ul class="flex flex-col gap-4">
{#each data.geschichten as g (g.id)}
<li
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
>
<a href="/geschichten/{g.id}" class="block">
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
<p class="mb-3 font-sans text-xs text-ink-3">
{authorName(g)}
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
</p>
{#if g.body}
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>