Files
familienarchiv/frontend/src/lib/timeline/EventHeader.svelte
Marcel 621248f941
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 8m52s
CI / OCR Service Tests (pull_request) Successful in 2m58s
CI / Backend Unit Tests (pull_request) Failing after 13m32s
CI / fail2ban Regex (pull_request) Successful in 1m52s
CI / Semgrep Security Scan (pull_request) Successful in 43s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m51s
SDD Gate / RTM Check (pull_request) Successful in 26s
SDD Gate / Contract Validate (pull_request) Successful in 40s
SDD Gate / Constitution Impact (pull_request) Successful in 31s
refactor(timeline): extract shared EventHeader for pill + event-card
EventCluster's same-year header re-implemented EventPill's glyph circle,
serif title, provenance subtitle, and the curator edit anchor near-
verbatim — the third copy of that markup. They now share a single
EventHeader component (glyph via GlyphLabel, title, `{date} · provenance`
subtitle, optional sr-only letter count, and the canEditEvent-gated edit
pencil); EventPill keeps only its pill border, EventCluster only its card
chrome. Second half of review finding #5 (Architect-1). No behavior
change — EventPill/EventCluster/YearBand/TimelineView specs stay green.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:38:21 +02:00

70 lines
2.8 KiB
Svelte

<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import GlyphLabel from './GlyphLabel.svelte';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* The shared header for a curated or derived timeline event — the accent glyph circle, the title,
* and the `{date} · {kuratiert|abgeleitet}` subtitle, plus a curator edit pencil gated by the
* single canEditEvent() contract. Rendered by EventPill (inside the floating axis pill) and by
* EventCluster (as a same-year event-card header), so the glyph/title/subtitle markup and the
* security-relevant edit gate live in one place (#850 finding #5). It renders three sibling nodes
* (glyph circle, text block, optional edit pencil) into the parent's flex row — the parent owns
* the wrapper (pill vs card header). An optional letter `count` appends a screen-reader-labeled
* "· {count}" for the event-card case.
*/
let {
entry,
canWrite = false,
count = undefined
}: { entry: TimelineEntryDTO; canWrite?: boolean; count?: number } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived: a derived life-event is "abgeleitet", a curated event
// "kuratiert"; the date is an optional prefix so an undated event still reads the provenance.
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canEditEvent(entry, canWrite));
</script>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent === 'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<GlyphLabel glyph={config.glyph} label={config.label} />
</span>
<span class="min-w-0 text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
<span class="block font-sans text-xs text-ink-3">
{subtitle}
{#if count !== undefined}
<span data-testid="event-count">
<span aria-hidden="true">· {count}</span>
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
</span>
{/if}
</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}