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

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({

View File

@@ -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>

View File

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

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

View File

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

View File

@@ -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'
}
]