The security-relevant edit-affordance gate (canWrite && !derived && eventId != null) was copied into EventPill, WorldBand, and EventCluster — three places for one load-bearing contract, inviting drift. It now lives once as canEditEvent(entry, canWrite) in eventCardConfig, and all three call it. No behavior change (HISTORICAL is never derived, so WorldBand's gate is unchanged). First half of review finding #5 (Architect-1). Refs #850 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
2.7 KiB
Svelte
68 lines
2.7 KiB
Svelte
<script lang="ts">
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
|
import { timelineDateLabel } from './dateLabel';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|
|
|
/**
|
|
* Centered axis pill for a derived life-event or a curated PERSONAL event
|
|
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
|
|
* (REQ-018). An edit affordance shows only for a curated event with an eventId
|
|
* (never derived, never null — REQ-008) and only for a curator who holds
|
|
* WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The
|
|
* gate is UX only; the real boundary is the #781 route guard + backend permission.
|
|
*/
|
|
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
|
|
|
|
const config = $derived(getAccentConfig(entry));
|
|
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
|
// Provenance reads off entry.derived (not the accent): a derived life-event is
|
|
// "abgeleitet", a curated PERSONAL event is "kuratiert" (REQ-007).
|
|
const provenance = $derived(
|
|
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
|
);
|
|
// Provenance always shows; the date is an optional prefix so an undated event
|
|
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
|
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
|
const canEdit = $derived(canEditEvent(entry, canWrite));
|
|
</script>
|
|
|
|
<div class="flex justify-center">
|
|
<div
|
|
class="inline-flex items-center gap-2 rounded-full bg-surface px-3 py-1 shadow-sm {config.accent ===
|
|
'curated'
|
|
? 'border-2 border-brand-mint'
|
|
: 'border border-brand-navy'}"
|
|
>
|
|
<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'}"
|
|
>
|
|
<span aria-hidden="true">{config.glyph}</span>
|
|
<span class="sr-only">{config.label}</span>
|
|
</span>
|
|
<span class="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}</span>
|
|
</span>
|
|
{#if canEdit}
|
|
<a
|
|
data-testid="event-edit"
|
|
href="/zeitstrahl/events/{entry.eventId}/edit"
|
|
class="ml-1 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}
|
|
</div>
|
|
</div>
|