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

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:
Marcel
2026-06-10 19:59:32 +02:00
parent b0d75b26cd
commit 90a1bd4082
11 changed files with 203 additions and 182 deletions

View File

@@ -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
})
}
});