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:
120
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
120
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||||
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
canBlogWrite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte: g, canBlogWrite }: Props = $props();
|
||||||
|
|
||||||
|
const sanitized = $derived(safeHtml(g.body));
|
||||||
|
|
||||||
|
const confirm = getConfirmService();
|
||||||
|
|
||||||
|
function personName(p: { firstName?: string; lastName?: string }): string {
|
||||||
|
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
>
|
||||||
|
{personName(p)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dokumente (JourneyItems) -->
|
||||||
|
{#if g.items && g.items.some((i) => i.document)}
|
||||||
|
<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.document) as item (item.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/documents/{item.document!.id}"
|
||||||
|
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}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Author actions -->
|
||||||
|
{#if 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}
|
||||||
135
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
135
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const { default: StoryReader } = await import('./StoryReader.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Die Reise nach Berlin',
|
||||||
|
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
||||||
|
type: 'STORY',
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
author: { id: 'u1', displayName: 'Anna Schmidt' },
|
||||||
|
persons: [],
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
|
||||||
|
|
||||||
|
describe('StoryReader', () => {
|
||||||
|
it('renders body HTML content', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte(), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits persons section when persons array is empty', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders persons section with firstName + lastName joined', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
persons: [
|
||||||
|
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||||
|
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Helene Schmidt')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Karl Müller')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits documents section when no items have documents', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders documents section for items with documents', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'i1',
|
||||||
|
position: 0,
|
||||||
|
document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' },
|
||||||
|
note: 'Wichtiger Brief'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Dokument öffnen')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows edit/delete actions when canBlogWrite is true', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte(), canBlogWrite: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('link', { name: /bearbeiten/i }))
|
||||||
|
.toHaveAttribute('href', '/geschichten/g1/edit');
|
||||||
|
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides edit/delete actions when canBlogWrite is false', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte(), canBlogWrite: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
||||||
|
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
body: '<img src=x onerror="(window as any).__xss_story=1">'
|
||||||
|
}),
|
||||||
|
canBlogWrite: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as { __xss_story?: number }).__xss_story).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { safeHtml } from '$lib/shared/utils/sanitize';
|
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
|
||||||
import BackButton from '$lib/shared/primitives/BackButton.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';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const g = $derived(data.geschichte);
|
const g = $derived(data.geschichte);
|
||||||
const sanitized = $derived(safeHtml(g.body));
|
const isJourney = $derived(g.type === 'JOURNEY');
|
||||||
|
|
||||||
const publishedAt = $derived.by(() => {
|
const publishedAt = $derived.by(() => {
|
||||||
if (!g.publishedAt) return null;
|
if (!g.publishedAt) return null;
|
||||||
@@ -19,27 +17,7 @@ const publishedAt = $derived.by(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function authorName(): string {
|
function authorName(): string {
|
||||||
const a = g.author;
|
return g.author?.displayName ?? '';
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -50,93 +28,28 @@ async function handleDelete() {
|
|||||||
|
|
||||||
<article aria-labelledby="geschichte-title">
|
<article aria-labelledby="geschichte-title">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||||
{g.title}
|
<h1 id="geschichte-title" class="font-serif text-4xl font-bold text-ink">
|
||||||
</h1>
|
{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">
|
<p class="font-sans text-sm text-ink-3">
|
||||||
{authorName()}
|
{authorName()}
|
||||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!--
|
{#if isJourney}
|
||||||
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} />
|
||||||
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
{:else}
|
||||||
and produces a much narrower column inside an already narrow page, which
|
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} />
|
||||||
Leonie flagged as unreadable for the senior-author persona.
|
{/if}
|
||||||
|
|
||||||
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>
|
|
||||||
</article>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user