From 90a1bd40829d31f1858b8e0d927fe70c8e660d4e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 19:59:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(journey-reader):=20match=20spec=20LR-2=20?= =?UTF-8?q?=E2=80=94=20card=20layout,=20interlude,=20badge,=20actions;=20i?= =?UTF-8?q?nline=20note=20in=20editor=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JourneyItemCard: restructure from full- 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 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + .../lib/geschichte/JourneyInterlude.svelte | 10 +-- .../src/lib/geschichte/JourneyItemCard.svelte | 71 ++++++++++----- .../geschichte/JourneyItemCard.svelte.spec.ts | 73 +++++++++------ .../src/lib/geschichte/JourneyItemRow.svelte | 89 +++++++++---------- .../src/lib/geschichte/JourneyReader.svelte | 29 ++---- .../geschichte/JourneyReader.svelte.spec.ts | 58 ++++-------- .../src/routes/geschichten/[id]/+page.svelte | 47 ++++++---- .../geschichten/[id]/page.svelte.test.ts | 2 +- 11 files changed, 203 insertions(+), 182 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 63c21073..5c97fc2d 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9126f19a..9dffd749 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 34dd3ab9..c1112d89 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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.", diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte index 9bfcca9e..9b9a180a 100644 --- a/frontend/src/lib/geschichte/JourneyInterlude.svelte +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte @@ -11,14 +11,8 @@ let { note }: Props = $props();
- -

{note}

+

{note}

diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte b/frontend/src/lib/geschichte/JourneyItemCard.svelte index 3788ed48..586b2060 100644 --- a/frontend/src/lib/geschichte/JourneyItemCard.svelte +++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte @@ -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); -
- {doc.title} - {#if formattedDate} - {formattedDate} - {/if} - - -{#if hasNote} - -

- - {item.note} -

-{/if} +
+
+ +

{doc.title}

+ {#if metaLine} +

{metaLine}

+ {/if} + + + + + + {m.journey_item_open()} + + + + + {#if hasNote} + +
+

{item.note}

+
+ {/if} +
+
diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts index 75a77516..39e19681 100644 --- a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts @@ -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 => 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 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({ diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte index 57ea5c3e..b11a38be 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -107,21 +107,21 @@ async function handleRemoveCancel() { : 'border-line bg-surface' ].join(' ')} > -
+
-
+
- +
{#if isInterlude} {index + 1}. {item.document!.title} {/if} + + {#if showNote} +
+ +
+

{m.journey_note_save_hint()}

+ {#if !isInterlude} + + {/if} +
+ {#if noteError} + + {/if} +
+ {:else if !isInterlude} + + {/if}
-
+
{#if pendingRemove} {m.journey_item_pending_remove()} @@ -210,45 +248,4 @@ async function handleRemoveCancel() { {/if}
- - - {#if showNote} -
- -
-

{m.journey_note_save_hint()}

- {#if !isInterlude} - - {/if} -
- {#if noteError} - - {/if} -
- {:else if !isInterlude} -
- -
- {/if}
diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte b/frontend/src/lib/geschichte/JourneyReader.svelte index e508c723..8eabe882 100644 --- a/frontend/src/lib/geschichte/JourneyReader.svelte +++ b/frontend/src/lib/geschichte/JourneyReader.svelte @@ -9,11 +9,9 @@ type JourneyItemView = components['schemas']['JourneyItemView']; interface Props { geschichte: GeschichteView; - canBlogWrite: boolean; - ondelete?: () => Promise; } -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} -

{introText}

+

+ {introText} +

{/if} {#if validItems.length === 0} @@ -49,22 +51,3 @@ const validItems = $derived( {/each} {/if} - - -{#if canBlogWrite} -
- - {m.btn_edit()} - - -
-{/if} diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts index 1a2abe68..47e5d749 100644 --- a/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts @@ -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 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: '' - }), - canBlogWrite: false + }) } }); diff --git a/frontend/src/routes/geschichten/[id]/+page.svelte b/frontend/src/routes/geschichten/[id]/+page.svelte index b691bc0e..1c063abc 100644 --- a/frontend/src/routes/geschichten/[id]/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/+page.svelte @@ -54,22 +54,39 @@ async function handleDelete() {
-
-

- {g.title} -

- {#if isJourney} - - {m.journey_badge_detail()} - + {#if isJourney} + + {m.journey_badge_detail()} + + {/if} +

+ {g.title} +

+
+

+ {authorName} + {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} +

+ {#if isJourney && data.canBlogWrite} +
+ + {m.btn_edit()} + + +
{/if}
-

- {authorName} - {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} -

{#if deleteError} @@ -82,7 +99,7 @@ async function handleDelete() { {/if} {#if isJourney} - + {:else} {/if} diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index 4f0a3a11..88386c85 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -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' } ]