feat(lesereisen): StoryReader — extract body/persons/docs/actions, isJourney badge in detail header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-08 22:57:51 +02:00
parent 8fea94cb61
commit 8a6bc27979
3 changed files with 276 additions and 108 deletions

View File

@@ -1,17 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize';
import { formatDate } from '$lib/shared/utils/date';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { csrfFetch } from '$lib/shared/cookies';
import StoryReader from '$lib/geschichte/StoryReader.svelte';
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const g = $derived(data.geschichte);
const sanitized = $derived(safeHtml(g.body));
const isJourney = $derived(g.type === 'JOURNEY');
const publishedAt = $derived.by(() => {
if (!g.publishedAt) return null;
@@ -19,27 +17,7 @@ const publishedAt = $derived.by(() => {
});
function authorName(): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
const confirm = getConfirmService();
async function handleDelete() {
const ok = await confirm.confirm({
title: m.geschichte_delete_confirm_title(),
body: m.geschichte_delete_confirm_body(),
confirmLabel: m.btn_delete(),
cancelLabel: m.btn_cancel(),
destructive: true
});
if (!ok) return;
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
if (res.ok) {
goto('/geschichten');
}
return g.author?.displayName ?? '';
}
</script>
@@ -50,93 +28,28 @@ async function handleDelete() {
<article aria-labelledby="geschichte-title">
<header class="mb-6">
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
{g.title}
</h1>
<div class="mb-3 flex flex-wrap items-center gap-2">
<h1 id="geschichte-title" class="font-serif text-4xl font-bold text-ink">
{g.title}
</h1>
{#if isJourney}
<span
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
>
{m.journey_badge_detail()}
</span>
{/if}
</div>
<p class="font-sans text-sm text-ink-3">
{authorName()}
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
</p>
</header>
<!--
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
and produces a much narrower column inside an already narrow page, which
Leonie flagged as unreadable for the senior-author persona.
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
-->
<div
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html sanitized}
</div>
{#if isJourney}
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} />
{:else}
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} />
{/if}
</article>
<!-- Personen -->
{#if g.persons && g.persons.length > 0}
<section class="mt-10 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_persons_section()}
</h2>
<ul class="flex flex-wrap gap-2">
{#each g.persons as p (p.id)}
<li>
<a
href="/persons/{p.id}"
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
>
{p.displayName}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Dokumente (JourneyItems) -->
{#if g.items && g.items.some((i) => i.documentId)}
<section class="mt-8 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_documents_section()}
</h2>
<ul class="flex flex-col gap-2">
{#each g.items.filter((i) => i.documentId) as item (item.id)}
<!-- TODO(#786): replace placeholder with actual document title once journey reader is built -->
<li>
<a
href="/documents/{item.documentId}"
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.geschichten_document_link_placeholder()}
</a>
{#if item.note}
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<!-- Author actions -->
{#if data.canBlogWrite}
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
<a
href="/geschichten/{g.id}/edit"
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"
>
{m.btn_edit()}
</a>
<button
type="button"
onclick={handleDelete}
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>