feat(journey-reader): match spec LR-2 — card layout, interlude, badge, actions; inline note in editor row
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m3s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m11s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m3s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m11s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
JourneyItemCard: restructure from full-<a> to div+card with meta line (date · von X an Y) and explicit "Brief öffnen →" link; note renders as mint-border annotation inside the card. JourneyInterlude: remove ❦ ornament; orange-400 left-border spec classes. JourneyReader: fix intro classes (dashed border-b); remove bottom author actions (moved to +page.svelte metabar). +page.svelte geschichten/[id]: badge above title with spec orange-50 classes; Bearbeiten/Löschen in metabar right side for isJourney + canBlogWrite. JourneyItemRow: items-center on main row; drag handle self-center; note textarea inline in content column (removes border-t section below). i18n: add journey_item_open, journey_item_meta_from_to to de/en/es. Tests: update JourneyItemCard + JourneyReader specs to match new structure; fix datePrecision 'FULL'→'DAY', add receiverCount: 0 to all test fixtures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1175,6 +1175,8 @@
|
||||
"journey_create_submit": "Lesereise erstellen",
|
||||
"journey_item_open_aria": "Brief vom {date} öffnen",
|
||||
"journey_item_open_aria_undated": "Brief öffnen",
|
||||
"journey_item_open": "Brief öffnen",
|
||||
"journey_item_meta_from_to": "von {sender} an {receiver}",
|
||||
"journey_empty_state": "Diese Lesereise ist noch leer.",
|
||||
"journey_interlude_aria_label": "Kuratorennotiz",
|
||||
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
|
||||
|
||||
@@ -1175,6 +1175,8 @@
|
||||
"journey_create_submit": "Create reading journey",
|
||||
"journey_item_open_aria": "Open letter from {date}",
|
||||
"journey_item_open_aria_undated": "Open letter",
|
||||
"journey_item_open": "Open letter",
|
||||
"journey_item_meta_from_to": "from {sender} to {receiver}",
|
||||
"journey_empty_state": "This reading journey is still empty.",
|
||||
"journey_interlude_aria_label": "Curator's note",
|
||||
"journey_selector_aria_live_hint": "Please select a type to continue.",
|
||||
|
||||
@@ -1175,6 +1175,8 @@
|
||||
"journey_create_submit": "Crear viaje de lectura",
|
||||
"journey_item_open_aria": "Abrir carta del {date}",
|
||||
"journey_item_open_aria_undated": "Abrir carta",
|
||||
"journey_item_open": "Abrir carta",
|
||||
"journey_item_meta_from_to": "de {sender} a {receiver}",
|
||||
"journey_empty_state": "Este viaje de lectura está vacío.",
|
||||
"journey_interlude_aria_label": "Nota del curador",
|
||||
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.",
|
||||
|
||||
@@ -11,14 +11,8 @@ let { note }: Props = $props();
|
||||
<div
|
||||
role="note"
|
||||
aria-label={m.journey_interlude_aria_label()}
|
||||
class="my-2 border-l-4 border-journey-border bg-journey-tint px-4 py-3"
|
||||
class="my-4 rounded-r-sm border-l-2 border-orange-400 bg-orange-50 py-2 pr-3 pl-3"
|
||||
>
|
||||
<p
|
||||
class="text-center font-sans text-xs tracking-widest text-journey uppercase"
|
||||
aria-hidden="true"
|
||||
>
|
||||
❦
|
||||
</p>
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="font-serif text-base leading-relaxed text-ink-2 italic">{note}</p>
|
||||
<p class="text-xs leading-relaxed text-ink italic">{note}</p>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,17 @@ let { item }: Props = $props();
|
||||
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
||||
const doc = $derived(item.document!);
|
||||
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const ariaLabel = $derived(
|
||||
const metaLine = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (formattedDate) parts.push(formattedDate);
|
||||
if (doc.senderName && doc.receiverName) {
|
||||
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
|
||||
} else if (doc.senderName) {
|
||||
parts.push(doc.senderName);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
});
|
||||
const openAriaLabel = $derived(
|
||||
formattedDate
|
||||
? m.journey_item_open_aria({ date: formattedDate })
|
||||
: m.journey_item_open_aria_undated()
|
||||
@@ -22,22 +32,43 @@ const ariaLabel = $derived(
|
||||
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label={ariaLabel}
|
||||
style="display: flex; min-height: 44px; flex-direction: column"
|
||||
class="flex min-h-[44px] flex-col gap-1 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"
|
||||
>
|
||||
<span class="font-bold">{doc.title}</span>
|
||||
{#if formattedDate}
|
||||
<span class="font-sans text-sm text-ink-3">{formattedDate}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if hasNote}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mt-1 flex items-baseline gap-1 font-sans text-sm text-ink-3">
|
||||
<span aria-hidden="true">✎</span>
|
||||
{item.note}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mb-3">
|
||||
<div class="rounded-sm border border-line bg-white p-3">
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mb-0.5 font-serif text-sm leading-snug text-ink">{doc.title}</p>
|
||||
{#if metaLine}
|
||||
<p class="mb-2 text-xs text-ink-3">{metaLine}</p>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label={openAriaLabel}
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
|
||||
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
|
||||
<path
|
||||
d="M3 4h4M3 6.5h4M3 9h2"
|
||||
stroke="currentColor"
|
||||
stroke-width=".7"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{m.journey_item_open()}
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 10" fill="none">
|
||||
<path
|
||||
d="M4 2l4 3-4 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{#if hasNote}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<div class="border-mint mt-3 rounded-r-sm border-l-2 bg-surface py-1.5 pr-2 pl-3">
|
||||
<p class="text-xs leading-relaxed text-ink-2 italic">{item.note}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
|
||||
@@ -22,7 +23,8 @@ const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView =>
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
documentDate: '1923-05-15',
|
||||
datePrecision: 'FULL'
|
||||
datePrecision: 'DAY',
|
||||
receiverCount: 0
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
@@ -34,46 +36,46 @@ describe('JourneyItemCard', () => {
|
||||
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the document date when documentDate is present', async () => {
|
||||
it('renders the document date in the meta line when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('whole card is a single <a> element', async () => {
|
||||
it('"Brief öffnen" link points to /documents/:id', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.href).toContain('/documents/d1');
|
||||
const link = page.getByRole('link', { name: /öffnen/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
const el = await link.element();
|
||||
expect(el.getAttribute('href')).toContain('/documents/d1');
|
||||
});
|
||||
|
||||
it('link has dated aria-label when documentDate is present', async () => {
|
||||
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.getAttribute('aria-label')).toContain('Brief');
|
||||
expect(link?.getAttribute('aria-label')).toContain('1923');
|
||||
const link = page.getByRole('link', { name: /1923/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('link has undated aria-label when documentDate is absent', async () => {
|
||||
it('"Brief öffnen" link has undated aria-label when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
|
||||
const link = page.getByRole('link', { name: m.journey_item_open_aria_undated() });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits date text when documentDate is absent', async () => {
|
||||
it('omits date from meta line when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
|
||||
})
|
||||
}
|
||||
});
|
||||
@@ -81,35 +83,48 @@ describe('JourneyItemCard', () => {
|
||||
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ✎ glyph and note text when note is present', async () => {
|
||||
it('renders sender→receiver in meta line when both are present', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
documentDate: '1923-05-15',
|
||||
datePrecision: 'DAY',
|
||||
senderName: 'Franz Raddatz',
|
||||
receiverName: 'Emma Müller',
|
||||
receiverCount: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Franz Raddatz/)).toBeVisible();
|
||||
await expect.element(page.getByText(/Emma Müller/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders note as annotation block when note is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
expect(document.body.textContent).toContain('✎');
|
||||
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits annotation block when note is blank or whitespace', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
|
||||
|
||||
expect(document.body.textContent).not.toContain('✎');
|
||||
await expect.element(page.getByText(/ {3}/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits annotation block when note is absent', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
|
||||
|
||||
expect(document.body.textContent).not.toContain('✎');
|
||||
});
|
||||
|
||||
it('link meets 44px touch-target minimum height', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
const rect = link?.getBoundingClientRect();
|
||||
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||
const notes = document.querySelectorAll('[class*="border-mint"]');
|
||||
expect(notes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// Note uses Svelte text interpolation ({note}), NOT {@html}.
|
||||
// Note uses Svelte text interpolation ({item.note}), NOT {@html}.
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
|
||||
@@ -107,21 +107,21 @@ async function handleRemoveCancel() {
|
||||
: 'border-line bg-surface'
|
||||
].join(' ')}
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-1 px-2 py-2">
|
||||
<div class="flex min-w-0 items-center gap-1 px-2 py-1">
|
||||
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
|
||||
<button
|
||||
type="button"
|
||||
data-drag-handle
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
|
||||
class="hidden shrink-0 cursor-grab items-center justify-center self-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
|
||||
style="min-height: 44px; min-width: 44px;"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
|
||||
<!-- Move up/down (mobile + always visible) -->
|
||||
<div class="flex shrink-0 flex-col">
|
||||
<div class="flex shrink-0 flex-col self-start">
|
||||
<button
|
||||
type="button"
|
||||
data-move-up
|
||||
@@ -147,7 +147,7 @@ async function handleRemoveCancel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<!-- Content (title + note inline) -->
|
||||
<div class="min-w-0 flex-1 py-1 break-words">
|
||||
{#if isInterlude}
|
||||
<span
|
||||
@@ -160,10 +160,48 @@ async function handleRemoveCancel() {
|
||||
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
|
||||
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
|
||||
{/if}
|
||||
|
||||
{#if showNote}
|
||||
<div class="mt-2">
|
||||
<textarea
|
||||
aria-label={m.journey_note_aria_label({ title: itemTitle })}
|
||||
bind:value={noteDraft}
|
||||
onblur={handleNoteBlur}
|
||||
maxlength={2000}
|
||||
rows={2}
|
||||
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
|
||||
{#if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNoteRemove}
|
||||
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_remove()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if noteError}
|
||||
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showNote = true;
|
||||
}}
|
||||
class="mt-0.5 inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_add()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button / confirm / pending -->
|
||||
<div class="shrink-0">
|
||||
<div class="shrink-0 self-start">
|
||||
{#if pendingRemove}
|
||||
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
|
||||
{m.journey_item_pending_remove()}
|
||||
@@ -210,45 +248,4 @@ async function handleRemoveCancel() {
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note section -->
|
||||
{#if showNote}
|
||||
<div class="border-t border-line/50 px-3 pt-2 pb-3">
|
||||
<textarea
|
||||
aria-label={m.journey_note_aria_label({ title: itemTitle })}
|
||||
bind:value={noteDraft}
|
||||
onblur={handleNoteBlur}
|
||||
maxlength={2000}
|
||||
rows={2}
|
||||
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
|
||||
{#if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNoteRemove}
|
||||
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_remove()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if noteError}
|
||||
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !isInterlude}
|
||||
<div class="px-3 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showNote = true;
|
||||
}}
|
||||
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_add()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,9 @@ type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
canBlogWrite: boolean;
|
||||
ondelete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
||||
let { geschichte: g }: Props = $props();
|
||||
|
||||
// Render intro only when body is a non-empty, non-whitespace string.
|
||||
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||
@@ -29,7 +27,11 @@ const validItems = $derived(
|
||||
|
||||
{#if introText}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p>
|
||||
<p
|
||||
class="border-subtle mb-6 border-b border-dashed pb-4 font-serif text-sm leading-relaxed text-ink-2 italic"
|
||||
>
|
||||
{introText}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if validItems.length === 0}
|
||||
@@ -49,22 +51,3 @@ const validItems = $derived(
|
||||
{/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={() => ondelete?.()}
|
||||
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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { page } from 'vitest/browser';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -33,7 +33,13 @@ const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView
|
||||
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
|
||||
id,
|
||||
position,
|
||||
document: { id: `d${id}`, title, datePrecision: 'FULL', documentDate: '1923-05-15' },
|
||||
document: {
|
||||
id: `d${id}`,
|
||||
title,
|
||||
datePrecision: 'DAY',
|
||||
documentDate: '1923-05-15',
|
||||
receiverCount: 0
|
||||
},
|
||||
note
|
||||
});
|
||||
|
||||
@@ -50,10 +56,7 @@ describe('JourneyReader', () => {
|
||||
it('renders intro paragraph when body is non-empty', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }),
|
||||
canBlogWrite: false
|
||||
}
|
||||
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
|
||||
@@ -62,7 +65,7 @@ describe('JourneyReader', () => {
|
||||
it('omits intro paragraph when body is null', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false }
|
||||
props: { geschichte: baseGeschichte({ body: undefined }) }
|
||||
});
|
||||
|
||||
// Only empty state should render
|
||||
@@ -72,7 +75,7 @@ describe('JourneyReader', () => {
|
||||
it('omits intro paragraph when body is only whitespace', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
|
||||
props: { geschichte: baseGeschichte({ body: ' ' }) }
|
||||
});
|
||||
|
||||
// Whitespace-only body must NOT produce a visible intro paragraph.
|
||||
@@ -85,7 +88,7 @@ describe('JourneyReader', () => {
|
||||
it('renders empty-state message when items array is empty', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
|
||||
props: { geschichte: baseGeschichte({ items: [] }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||
@@ -95,11 +98,7 @@ describe('JourneyReader', () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
body: 'Eine Einleitung.',
|
||||
items: []
|
||||
}),
|
||||
canBlogWrite: false
|
||||
geschichte: baseGeschichte({ body: 'Eine Einleitung.', items: [] })
|
||||
}
|
||||
});
|
||||
|
||||
@@ -111,8 +110,7 @@ describe('JourneyReader', () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }),
|
||||
canBlogWrite: false
|
||||
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] })
|
||||
}
|
||||
});
|
||||
|
||||
@@ -123,13 +121,11 @@ describe('JourneyReader', () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }),
|
||||
canBlogWrite: false
|
||||
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] })
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
|
||||
expect(document.body.textContent).toContain('❦');
|
||||
});
|
||||
|
||||
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
|
||||
@@ -141,8 +137,7 @@ describe('JourneyReader', () => {
|
||||
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
|
||||
docItem('item2', 'Echter Brief', 1)
|
||||
]
|
||||
}),
|
||||
canBlogWrite: false
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,22 +146,6 @@ describe('JourneyReader', () => {
|
||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking delete button calls ondelete prop', async () => {
|
||||
const ondelete = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ items: [docItem('i1', 'Brief', 0)] }),
|
||||
canBlogWrite: true,
|
||||
ondelete
|
||||
}
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
|
||||
|
||||
expect(ondelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
||||
render(JourneyReader, {
|
||||
@@ -174,8 +153,7 @@ describe('JourneyReader', () => {
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
body: '<img src=x onerror="window.__xss_journey=1">'
|
||||
}),
|
||||
canBlogWrite: false
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,22 +54,39 @@ async function handleDelete() {
|
||||
|
||||
<article aria-labelledby="geschichte-title">
|
||||
<header class="mb-6">
|
||||
<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 isJourney}
|
||||
<span
|
||||
class="mb-2 inline-flex rounded-sm border border-orange-200 bg-orange-50 px-2 py-px text-[10px] font-bold tracking-widest text-orange-700 uppercase"
|
||||
>
|
||||
{m.journey_badge_detail()}
|
||||
</span>
|
||||
{/if}
|
||||
<h1 id="geschichte-title" class="mb-4 font-serif text-3xl leading-tight font-bold text-ink">
|
||||
{g.title}
|
||||
</h1>
|
||||
<div class="border-subtle mb-4 flex items-center gap-3 border-b pb-4">
|
||||
<p class="font-sans text-sm text-ink-3">
|
||||
{authorName}
|
||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||
</p>
|
||||
{#if isJourney && data.canBlogWrite}
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<a
|
||||
href="/geschichten/{g.id}/edit"
|
||||
class="inline-flex h-9 items-center rounded border border-line bg-surface px-3 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-9 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>
|
||||
<p class="font-sans text-sm text-ink-3">
|
||||
{authorName}
|
||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if deleteError}
|
||||
@@ -82,7 +99,7 @@ async function handleDelete() {
|
||||
{/if}
|
||||
|
||||
{#if isJourney}
|
||||
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||
<JourneyReader geschichte={g} />
|
||||
{:else}
|
||||
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||
{/if}
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('geschichten/[id] page', () => {
|
||||
{
|
||||
id: 'item1',
|
||||
position: 0,
|
||||
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' },
|
||||
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'DAY', receiverCount: 0 },
|
||||
note: 'Brief aus 1923'
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user