feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
89
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
89
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
canBlogWrite: boolean;
|
||||
}
|
||||
|
||||
let { geschichte: g, canBlogWrite }: Props = $props();
|
||||
|
||||
// Render intro only when body is a non-empty, non-whitespace string.
|
||||
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||
|
||||
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
|
||||
const validItems = $derived(
|
||||
g.items.filter(
|
||||
(item: JourneyItemView) =>
|
||||
item.document != null || (item.note != null && item.note.trim().length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
{#if introText}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p>
|
||||
{/if}
|
||||
|
||||
{#if validItems.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
|
||||
{m.journey_empty_state()}
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="flex list-none flex-col gap-4">
|
||||
{#each validItems as item (item.id)}
|
||||
<li>
|
||||
{#if item.document != null}
|
||||
<JourneyItemCard item={item} />
|
||||
{:else}
|
||||
<JourneyInterlude note={item.note!} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/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}
|
||||
Reference in New Issue
Block a user