Files
familienarchiv/frontend/src/lib/document/DocumentTopBar.svelte
Marcel 7d5a34edb7 refactor(document): extract DocumentTopBarActions from DocumentTopBar
Third Phase 5 split. The desktop action buttons — transcribe,
transcribe-stop, edit link, download link — become their own component
with a focused props interface (documentId, canWrite, isPdf,
transcribeMode bindable, filePath, originalFilename, fileUrl).

TDD: 8 tests covering empty render, transcribe button gating
(canWrite × isPdf × transcribeMode), stop-transcribe rendering, edit
link with documentId href, download link with filePath gating, all
hidden when in transcribe mode. After the test was red the component
was created.

DocumentTopBar dropped from 303 lines to 166. The orchestrator now
just composes BackButton, DocumentTopBarTitle, PersonChipRow,
OverflowPillButton, the details toggle, DocumentTopBarActions,
DocumentMobileMenu, and DocumentMetadataDrawer — each visual region
named in one or two words.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00

167 lines
4.9 KiB
Svelte

<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition';
import PersonChipRow from '$lib/person/PersonChipRow.svelte';
import OverflowPillButton from '$lib/shared/primitives/OverflowPillButton.svelte';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
import DocumentMobileMenu from './DocumentMobileMenu.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Doc = {
id: string;
title?: string | null;
originalFilename?: string | null;
documentDate?: string | null;
sender?: Person | null;
receivers?: Person[] | null;
filePath?: string | null;
contentType?: string | null;
location?: string | null;
status?: string | null;
tags?: Tag[] | null;
};
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
doc: Doc;
canWrite: boolean;
fileUrl: string;
transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
canBlogWrite?: boolean;
};
let {
doc,
canWrite,
fileUrl,
transcribeMode = $bindable(),
inferredRelationship = null,
geschichten = [],
canBlogWrite = false
}: Props = $props();
let detailsOpen = $state(false);
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
const receivers = $derived(doc.receivers ?? []);
const extraCount = $derived(Math.max(0, receivers.length - 2));
const overflowPersons = $derived(receivers.slice(2));
</script>
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
<!-- Main row -->
<div class="flex h-[75px] shrink-0 items-center pr-4 xs:h-[88px]">
<!-- Accent bar -->
<div class="h-full w-[3px] shrink-0 bg-primary"></div>
<!-- Back button -->
<BackButton
class="-ml-0.5 h-11 w-11 shrink-0 justify-center rounded-full hover:bg-muted"
showLabel={false}
/>
<!-- Divider -->
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
<!-- Title + meta -->
<DocumentTopBarTitle
title={doc.title}
originalFilename={doc.originalFilename}
documentDate={doc.documentDate}
/>
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
<PersonChipRow sender={doc.sender} receivers={receivers} abbreviated={true} extraCount={0} />
</div>
<!-- Overflow pill button (desktop) + status dot -->
{#if extraCount > 0}
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
{/if}
<!-- Details toggle -->
<button
type="button"
onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen}
aria-label={m.doc_details_toggle()}
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen
? 'border-primary bg-primary text-primary-fg'
: 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.doc_details_toggle()}
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {detailsOpen ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Divider between metadata and actions -->
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
<!-- Action buttons -->
<div class="flex shrink-0 items-center gap-1.5 font-sans">
<DocumentTopBarActions
documentId={doc.id}
canWrite={canWrite}
isPdf={!!isPdf}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}
fileUrl={fileUrl}
/>
{#if (canWrite && isPdf) || doc.filePath}
<div class="md:hidden">
<DocumentMobileMenu
canWrite={canWrite}
isPdf={!!isPdf}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}
fileUrl={fileUrl}
/>
</div>
{/if}
</div>
</div>
<!-- Metadata drawer -->
{#if detailsOpen}
<div transition:slide={{ duration: 200 }}>
<DocumentMetadataDrawer
documentDate={doc.documentDate ?? null}
location={doc.location ?? null}
status={doc.status ?? 'PLACEHOLDER'}
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship}
geschichten={geschichten}
documentId={doc.id}
canBlogWrite={canBlogWrite}
/>
</div>
{/if}
</div>