Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
Introduces `csrfFetch` (= `makeCsrfFetch(fetch)`) in cookies.ts as a drop-in fetch replacement that auto-injects X-XSRF-TOKEN on POST/PUT/PATCH/DELETE. Previously 8 call sites sent mutating requests without the CSRF header — annotation resize, comment POST/PATCH/DELETE, Geschichte CRUD, Stammbaum relationship creation, bulk-edit PATCH, and file upload — all would fail with CSRF_TOKEN_MISSING if the backend's cookie-based protection triggered. All 14 client-side mutating fetches now use csrfFetch; withCsrf/makeCsrfFetch remain in the API for injectable-fetch use cases (e.g. useTranscriptionBlocks). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
4.5 KiB
Svelte
144 lines
4.5 KiB
Svelte
<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 type { PageData } from './$types';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
const g = $derived(data.geschichte);
|
|
const sanitized = $derived(safeHtml(g.body));
|
|
|
|
const publishedAt = $derived.by(() => {
|
|
if (!g.publishedAt) return null;
|
|
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
|
});
|
|
|
|
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');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="mx-auto max-w-3xl px-4 py-8">
|
|
<div class="mb-6">
|
|
<BackButton />
|
|
</div>
|
|
|
|
<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>
|
|
<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>
|
|
</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 -->
|
|
{#if g.documents && g.documents.length > 0}
|
|
<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.documents as d (d.id)}
|
|
<li>
|
|
<a
|
|
href="/documents/{d.id}"
|
|
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
|
|
>
|
|
{d.title}
|
|
{#if d.documentDate}
|
|
<span class="ml-2 font-sans text-xs text-ink-3">
|
|
{formatDate(d.documentDate, 'short')}
|
|
</span>
|
|
{/if}
|
|
</a>
|
|
</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>
|