refactor: move document domain core to lib/document/

Moves ~25 components, utils (search, filename, groupDocuments,
documentStatusLabel, validateFile), bulkSelection store, and
TranscriptionSection sub-component. Fixes broken relative imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 13:56:36 +02:00
parent 422e86fbf1
commit e7f8aa5894
77 changed files with 47 additions and 47 deletions

View File

@@ -0,0 +1,535 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { onDestroy, onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import BulkDropZone from './BulkDropZone.svelte';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
import ScopeCard from './ScopeCard.svelte';
import UploadSaveBar from './UploadSaveBar.svelte';
import WhoWhenSection from './WhoWhenSection.svelte';
import DescriptionSection from './DescriptionSection.svelte';
import PdfViewer from '$lib/components/PdfViewer.svelte';
import { bulkTitleFromFilename } from '$lib/document/filename';
import type { Tag } from '$lib/components/TagInput.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
// passes the parsed `/api/documents/batch-metadata` response straight in, so
// the field names must match what the backend actually serializes (id, not
// documentId). The FileEntry built from each summary still uses both `id` and
// `documentId` so the save handler can drive the PATCH payload by UUID.
export type BulkEditEntry = {
id: string;
title: string;
pdfUrl: string;
};
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
let _confirmService: ConfirmService | null;
try {
_confirmService = getConfirmService();
} catch {
_confirmService = null;
}
let {
mode = 'upload',
initialSenderId = '',
initialSenderName = '',
initialReceivers = [],
initialEditEntries = []
}: {
mode?: 'upload' | 'edit';
initialSenderId?: string;
initialSenderName?: string;
initialReceivers?: Person[];
initialEditEntries?: BulkEditEntry[];
} = $props();
// --- File state ---
let files = new SvelteMap<string, FileEntry>();
let activeId = $state<string | null>(null);
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
let saving = $state(false);
// Partial-failure surface: when set, the last save aborted at chunk N of M.
let partialSaved = $state<{ done: number; total: number } | null>(null);
// --- Shared metadata ---
let senderId = $state(untrack(() => initialSenderId));
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
let dateIso = $state('');
let tags = $state<Tag[]>([]);
// Bulk-edit only — replace-on-non-blank semantics.
let archiveBox = $state('');
let archiveFolder = $state('');
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
// fetch upstream in the route — by the time this layout mounts, the metadata
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
// the script body's first execution (Felix C4 cycle 3).
onMount(() => {
if (mode !== 'edit') return;
for (const entry of untrack(() => initialEditEntries)) {
files.set(entry.id, {
id: entry.id,
documentId: entry.id,
title: entry.title,
status: 'idle',
previewUrl: entry.pdfUrl
});
if (!activeId) activeId = entry.id;
}
});
// --- Derived ---
const isMulti = $derived(files.size >= 2);
const activeFile = $derived(activeId ? files.get(activeId) : null);
// --- File management ---
function addFiles(newFiles: File[]) {
for (const file of newFiles) {
const id = crypto.randomUUID();
const title = bulkTitleFromFilename(file.name);
const previewUrl = URL.createObjectURL(file);
files.set(id, { id, file, title, status: 'idle', previewUrl });
if (!activeId) activeId = id;
}
}
function removeFile(id: string) {
const entry = files.get(id);
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
files.delete(id);
if (activeId === id) {
activeId = files.keys().next().value ?? null;
}
}
function setTitle(id: string, title: string) {
const entry = files.get(id);
if (entry) files.set(id, { ...entry, title });
}
function discardAll() {
for (const entry of files.values()) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
files.clear();
activeId = null;
chunkProgress = undefined;
}
async function handleDiscard() {
if (_confirmService) {
const ok = await _confirmService.confirm({
title: m.bulk_discard_all(),
body: m.bulk_discard_confirm(),
destructive: true
});
if (!ok) return;
}
if (mode === 'edit') {
// In edit mode the file map IS the user's bulk selection — discarding
// must clear the upstream store and bounce back to the list, otherwise
// the user is left on /documents/bulk-edit with an empty form and a
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
bulkSelectionStore.clear();
discardAll();
await goto('/documents');
return;
}
discardAll();
}
onDestroy(() => {
for (const entry of files.values()) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
});
// --- Save (upload mode) ---
async function saveUpload() {
const entries = Array.from(files.values());
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
const chunkSize = 10;
const chunks: FileEntry[][] = [];
for (let i = 0; i < entries.length; i += chunkSize) {
chunks.push(entries.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
let hadErrors = false;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const formData = new FormData();
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
const metadata = {
titles: chunk.map((e) => e.title),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
documentDate: dateIso || null,
tagNames: tags.map((t) => t.name)
};
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
// FormData with per-chunk progress. Session cookie is sent automatically
// by the browser for same-origin requests.
try {
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
const body = await res.json().catch(() => ({ errors: [] }));
const errorFilenames = new Set<string>(
(body.errors ?? []).map((err: { filename: string }) => err.filename)
);
if (!res.ok || errorFilenames.size > 0) {
hadErrors = true;
for (const entry of chunk) {
const filename = entry.file?.name;
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
if (isError) {
const e = files.get(entry.id);
if (e) files.set(entry.id, { ...e, status: 'error' });
}
}
}
} catch {
hadErrors = true;
for (const entry of chunk) {
const e = files.get(entry.id);
if (e) files.set(entry.id, { ...e, status: 'error' });
}
}
chunkProgress = { done: i + 1, total: chunks.length };
}
if (!hadErrors) goto('/documents');
}
// --- Save (edit mode) ---
async function saveBulkEdit() {
const entries = Array.from(files.values());
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
// failure so the user sees a deterministic "X of N saved" outcome.
const chunkSize = 500;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
chunks.push(ids.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
partialSaved = null;
const dto = {
tagNames: tags.map((t) => t.name),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
archiveBox: archiveBox || null,
archiveFolder: archiveFolder || null
};
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
const res = await fetch('/api/documents/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dto, documentIds: chunk })
});
if (!res.ok) {
// Network/server failure: the chunk did not apply. Mark its entries
// as errored, surface partial-save state, and stop.
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
const body = (await res.json().catch(() => null)) as {
updated: number;
errors: { id: string; message: string }[];
} | null;
if (body && body.errors && body.errors.length > 0) {
for (const err of body.errors) {
const e = files.get(err.id);
if (e) files.set(err.id, { ...e, status: 'error' });
}
}
} catch {
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
chunkProgress = { done: i + 1, total: chunks.length };
}
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
if (!stillErrored) {
bulkSelectionStore.clear();
goto('/documents');
}
}
async function save() {
if (saving) return;
saving = true;
try {
if (mode === 'edit') {
await saveBulkEdit();
} else {
await saveUpload();
}
} finally {
saving = false;
}
}
async function retrySave() {
partialSaved = null;
await save();
}
</script>
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
<!-- Topbar -->
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
<a
href="/documents"
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
{m.btn_back_to_overview()}
</a>
<span class="text-ink-3" aria-hidden="true">·</span>
<span class="font-serif text-sm font-bold text-ink">
{#if mode === 'edit'}
{m.bulk_edit_topbar_title()}
{:else}
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
{/if}
</span>
{#if isMulti}
<span class="ml-auto flex items-center gap-3">
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
{#if mode === 'edit'}
{m.bulk_edit_count_pill({ count: files.size })}
{:else}
{m.bulk_count_pill({ count: files.size })}
{/if}
</span>
<button
type="button"
data-testid="discard-all-btn"
onclick={handleDiscard}
class="text-xs font-medium text-red-600/70 hover:text-red-700"
>
{m.bulk_discard_all()}
</button>
</span>
{/if}
</div>
<!-- Split panel -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview / drop zone (55%) -->
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
{#if mode === 'upload' && files.size === 0}
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
<BulkDropZone onFilesAdded={addFiles} />
{:else if files.size > 0}
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
<div class="relative flex-1 overflow-hidden">
{#if activeFile}
<PdfViewer url={activeFile.previewUrl} />
{/if}
</div>
{#if isMulti}
<!-- File switcher strip pinned to bottom of left panel -->
<FileSwitcherStrip
files={Array.from(files.values())}
activeId={activeId ?? ''}
onSelect={(id) => (activeId = id)}
onRemove={removeFile}
/>
{/if}
{/if}
</div>
<!-- Right: metadata form (45%) -->
<div class="flex flex-[45] flex-col overflow-hidden">
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
<div
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
class:opacity-60={files.size === 0}
class:pointer-events-none={files.size === 0}
>
{#if mode === 'edit'}
<!-- Onboarding callout: tells the user that empty fields are skipped
and that tags/receivers are added rather than replaced.
No aria-label — role=note + the visible text content is
self-describing; an aria-label would override that text for
AT users on non-DE locales. -->
<div
role="note"
data-testid="bulk-edit-callout"
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
>
{m.bulk_edit_hint()}
</div>
{/if}
{#if isMulti}
<!-- N≥2: per-file card (title) + shared card (metadata) -->
<ScopeCard variant="per-file">
{#if activeFile}
{#if mode === 'edit'}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{/if}
{/if}
</ScopeCard>
<ScopeCard variant="shared" count={files.size}>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection
bind:tags={tags}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
</ScopeCard>
{:else}
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
{#if mode === 'edit' && activeFile}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
/>
{/if}
</label>
{/if}
</div>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection
bind:tags={tags}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
{/if}
{#if partialSaved}
<div
role="alert"
data-testid="bulk-edit-partial-failure"
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
>
<p class="font-medium">
{m.bulk_edit_save_partial({
done: partialSaved.done,
total: partialSaved.total
})}
</p>
<button
type="button"
onclick={retrySave}
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.bulk_edit_retry()}
</button>
</div>
{/if}
</div>
<!-- Action bar: always visible at bottom of right panel -->
<UploadSaveBar
fileCount={files.size}
chunkProgress={chunkProgress}
onSave={save}
onDiscard={handleDiscard}
disabled={saving}
editMode={mode === 'edit'}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,543 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { goto } from '$app/navigation';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function makeFile(name: string): File {
return new File(['content'], name, { type: 'application/pdf' });
}
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
await userEvent.upload(input, files);
}
describe('BulkDocumentEditLayout', () => {
it('N=0: shows BulkDropZone', async () => {
render(BulkDocumentEditLayout, {});
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
});
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('doc.pdf')]);
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
});
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [
makeFile('a.pdf'),
makeFile('b.pdf'),
makeFile('c.pdf'),
makeFile('d.pdf'),
makeFile('e.pdf')
]);
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
});
it('removing middle file preserves order of remaining files', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [
makeFile('file0.pdf'),
makeFile('file1.pdf'),
makeFile('file2.pdf')
]);
// Remove the chip for file1 via its remove button (identified by data-remove-id)
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
'[data-testid="file-switcher-strip"] button[data-remove-id]'
);
expect(removeButtons.length).toBe(3);
removeButtons[1].click(); // remove file1
// Wait for Svelte to flush the DOM update
await vi.waitFor(
() => {
const chips = container.querySelectorAll(
'[data-testid="file-switcher-strip"] [data-chip-id]'
);
expect(chips.length).toBe(2);
expect(chips[0].textContent?.trim()).toContain('file0');
expect(chips[1].textContent?.trim()).toContain('file2');
},
{ timeout: 1000 }
);
});
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ created: [], updated: [], errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
await addFilesViaInput(container, files);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
saveBtn.click();
// Wait for async save to complete
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
});
it('save marks file as error when server returns non-ok response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('f0.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
});
it('save() includes tagNames in metadata payload', async () => {
let capturedFormData: FormData | undefined;
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
capturedFormData = init?.body as FormData;
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('doc.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(capturedFormData).toBeDefined();
const metadataBlob = capturedFormData!.get('metadata') as Blob;
const metadataJson = JSON.parse(await metadataBlob.text());
expect(metadataJson).toHaveProperty('tagNames');
});
it('save() navigates to /documents when all chunks succeed', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ created: [], updated: [], errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('doc.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
});
it('save() does not navigate when chunk returns non-ok response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('f0.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(goto).not.toHaveBeenCalled();
});
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
await vi.waitFor(
() => {
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
expect(errorChips.length).toBe(1);
expect(errorChips[0].textContent).toContain('b');
},
{ timeout: 1000 }
);
});
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
// Backend can return 200 OK while reporting individual file failures
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
created: [{ id: '1' }],
updated: [],
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
})
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
await vi.waitFor(
() => {
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
expect(errorChips.length).toBe(1);
expect(errorChips[0].textContent).toContain('b');
},
{ timeout: 1000 }
);
// Navigation should be suppressed because hadErrors is true
expect(goto).not.toHaveBeenCalled();
});
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
expect(errorChips.length).toBe(2);
},
{ timeout: 3000 }
);
expect(goto).not.toHaveBeenCalled();
});
it('save() does not call fetch a second time when already saving', async () => {
let resolveFirst: (() => void) | undefined;
const mockFetch = vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveFirst = () =>
resolve({
ok: true,
json: async () => ({ created: [], updated: [], errors: [] })
} as Response);
})
);
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click(); // first click — fetch is in-flight
saveBtn.click(); // second click — should be a no-op
resolveFirst?.();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('discard-all resets to N=0 state and shows drop zone', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
// Confirm N=2 state — switcher is visible
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
// Click the topbar discard-all button (only visible in isMulti state)
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(
() => {
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
},
{ timeout: 1000 }
);
});
});
// ─── mode="edit" ─────────────────────────────────────────────────────────────
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
const { bulkSelectionStore } = await import('$lib/document/bulkSelection.svelte');
bulkSelectionStore.setAll(['doc-1']);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
]
});
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
expect(bulkSelectionStore.size).toBe(0);
});
});
describe('BulkDocumentEditLayout — mode="edit"', () => {
const editEntry = (i: number) => ({
id: `doc-${i}`,
title: `Brief ${i}`,
pdfUrl: `/api/documents/doc-${i}/file`
});
it('does not render the BulkDropZone in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
});
it('renders the onboarding callout with role=note in edit mode', async () => {
render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const callout = page.getByTestId('bulk-edit-callout');
await expect.element(callout).toBeInTheDocument();
await expect.element(callout).toHaveAttribute('role', 'note');
});
it('renders read-only title display (no input) in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
// Per-file ScopeCard absent at N=1 — title rendered in the single card
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
expect(titleInput).toBeNull();
});
it('hides the date field via WhoWhenSection hideDate prop', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
});
it('shows additive badge next to tags label', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
});
it('shows replace badges next to sender and archive fields', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
// sender + archiveBox + archiveFolder = 3
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
});
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
// "werden erstellt" copy must not appear when mode === 'edit'.
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
// Topbar title slot
const topbar = container.querySelector('span.font-bold.text-ink');
expect(topbar?.textContent).toContain('Massenbearbeitung');
// Count pill
const pill = container.querySelector('span.bg-accent');
expect(pill?.textContent).toContain('werden bearbeitet');
// Negative: must NOT show upload-flavoured copy
expect(topbar?.textContent ?? '').not.toContain('hochladen');
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
});
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 2, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/documents/bulk');
expect(init.method).toBe('PATCH');
const body = JSON.parse(init.body);
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
});
it('chunks IDs into 500-sized PATCH requests', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 500, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
});
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
expect(alert).not.toBeNull();
},
{ timeout: 5000 }
);
// Should have called twice — chunks 0 and 1 — but not the third.
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
it('marks per-document error chips when service returns errors[]', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
updated: 1,
errors: [{ id: 'doc-2', message: 'Sender not found' }]
})
})
);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const errorChip = container.querySelector(
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
);
expect(errorChip).not.toBeNull();
},
{ timeout: 3000 }
);
});
});

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
onFilesAdded
}: {
onFilesAdded: (files: File[]) => void;
} = $props();
let isDragging = $state(false);
</script>
<div
role="region"
aria-label={m.bulk_drop_zone_label()}
aria-describedby="bulk-drop-desc"
data-testid="bulk-drop-zone"
class="flex flex-1 flex-col items-center justify-center p-6"
ondragover={(e) => {
e.preventDefault();
isDragging = true;
}}
ondragleave={() => (isDragging = false)}
ondrop={(e) => {
e.preventDefault();
isDragging = false;
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
onFilesAdded(Array.from(e.dataTransfer.files));
}
}}
>
<div
class={[
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
].join(' ')}
>
<!-- Circular mint icon -->
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<polygon
fill="currentColor"
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
/>
</svg>
</div>
<!-- Serif title -->
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
<!-- Sub description -->
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
<!-- CTA button -->
<label
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
>
{m.bulk_select_files()}
<input
type="file"
multiple
accept="application/pdf"
class="sr-only"
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
if (files.length > 0) onFilesAdded(files);
}}
/>
</label>
<!-- Format hint -->
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import BulkDropZone from './BulkDropZone.svelte';
afterEach(cleanup);
describe('BulkDropZone', () => {
it('file input has multiple attribute', async () => {
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
const input = container.querySelector('input[type="file"]');
expect(input).not.toBeNull();
expect(input?.hasAttribute('multiple')).toBe(true);
});
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
const onFilesAdded = vi.fn();
render(BulkDropZone, { onFilesAdded });
const files = [
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
new File(['c'], 'c.pdf', { type: 'application/pdf' })
];
const input = page.getByRole('button', { name: /Dateien auswählen/i });
await userEvent.upload(input, files);
expect(onFilesAdded).toHaveBeenCalledOnce();
const received: File[] = onFilesAdded.mock.calls[0][0];
expect(received).toHaveLength(3);
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
});
it('shows drop hint text', async () => {
render(BulkDropZone, { onFilesAdded: vi.fn() });
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
let { canWrite }: { canWrite: boolean } = $props();
const count = $derived(bulkSelectionStore.size);
const visible = $derived(canWrite && count > 0);
function openBulkEdit() {
goto('/documents/bulk-edit');
}
function clearAll() {
bulkSelectionStore.clear();
}
// Escape clears the selection — keyboard escape hatch when the user has
// drilled into a 50-row selection and wants to bail without Tab-ing through
// the whole footer (WCAG 2.1.1). Bails when an open dialog, expanded menu,
// or popover is in front so we don't steal Esc from NotificationBell,
// ConfirmDialog, HelpPopover, etc.
function onEscape(e: KeyboardEvent) {
if (e.key !== 'Escape' || !visible) return;
if (e.defaultPrevented) return;
const overlay = document.querySelector(
'dialog[open], [aria-expanded="true"], [role="menu"]:not([hidden]), [role="dialog"]:not([hidden])'
);
if (overlay) return;
clearAll();
}
</script>
<svelte:window onkeydown={onEscape} />
{#if visible}
<div
data-testid="bulk-selection-bar"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
>
<div class="flex items-baseline gap-3">
<span
class="font-sans text-sm font-medium text-ink"
data-testid="bulk-selection-count"
aria-live="polite"
aria-atomic="true"
>
{count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}
</span>
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
{m.bulk_edit_clear_hint_keyboard()}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={clearAll}
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
data-testid="bulk-clear-all"
>
{m.bulk_edit_clear_selection()}
</button>
<button
type="button"
onclick={openBulkEdit}
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
data-testid="bulk-edit-open"
>
{m.bulk_edit_button()}
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import BulkSelectionBar from './BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
});
describe('BulkSelectionBar', () => {
it('does not render when canWrite is false', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: false });
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
});
it('does not render when selection is empty', async () => {
render(BulkSelectionBar, { canWrite: true });
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
});
it('renders with the current selection count', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
});
it('uses the singular plural form for count=1 (not "1 Dokumente")', async () => {
bulkSelectionStore.add('only');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('1 Dokument ausgewählt');
});
it('uses the plural form for count=2', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('2 Dokumente ausgewählt');
});
it('clear button empties the store', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await page.getByTestId('bulk-clear-all').click();
expect(bulkSelectionStore.size).toBe(0);
});
it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await page.getByTestId('bulk-edit-open').click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
});
it('selection count region announces via aria-live=polite', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveAttribute('aria-live', 'polite');
});
it('Escape clears the selection while the bar is visible', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await expect.poll(() => bulkSelectionStore.size).toBe(0);
});
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Nothing to clear, no error.
expect(bulkSelectionStore.size).toBe(0);
});
it('Escape does not clear when an open <dialog> is present (Leonie B6 scope guard)', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
// Simulate a ConfirmDialog being open in front of the bar.
const overlay = document.createElement('dialog');
overlay.setAttribute('open', '');
document.body.appendChild(overlay);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Escape is captured by the dialog, not the bar — selection survives.
expect(bulkSelectionStore.size).toBe(2);
} finally {
overlay.remove();
}
});
it('Escape does not clear when an aria-expanded popover is present', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
const trigger = document.createElement('button');
trigger.setAttribute('aria-expanded', 'true');
document.body.appendChild(trigger);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(bulkSelectionStore.size).toBe(1);
} finally {
trigger.remove();
}
});
});

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/relativeTime';
type IncompleteDoc = {
id: string;
title: string;
uploadedAt: string;
};
interface Props {
topDocs: IncompleteDoc[];
totalCount: number;
}
let { topDocs, totalCount }: Props = $props();
const showFooter = $derived(totalCount > 5);
</script>
{#if topDocs.length > 0}
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.dashboard_needs_metadata_heading()}
</h2>
<ul class="divide-y divide-line">
{#each topDocs as doc (doc.id)}
<li>
<a
href="/enrich/{doc.id}"
class="group flex items-center gap-3 py-3 text-ink hover:bg-accent-bg/40"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 opacity-50 group-hover:opacity-80"
/>
<div class="min-w-0 flex-1">
<div class="truncate font-serif text-base text-ink group-hover:underline">
<span class="sr-only">PDF: </span>{doc.title}
</div>
<div class="font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(doc.uploadedAt))}
</div>
</div>
<svg
class="h-4 w-4 shrink-0 text-ink-3 transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</li>
{/each}
</ul>
{#if showFooter}
<div class="mt-4">
<a
href="/enrich"
class="font-sans text-sm font-medium text-ink-2 hover:text-ink hover:underline"
>
{m.dashboard_needs_metadata_show_all_count({ count: totalCount })}
</a>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
afterEach(cleanup);
type IncompleteDoc = { id: string; title: string; uploadedAt: string };
function makeDoc(id: string, title: string, uploadedAt = '2026-04-20T12:00:00'): IncompleteDoc {
return { id, title, uploadedAt };
}
describe('DashboardNeedsMetadata', () => {
it('renders nothing when topDocs is empty', async () => {
render(DashboardNeedsMetadata, { topDocs: [], totalCount: 0 });
const widget = page.getByTestId('dashboard-needs-metadata');
await expect.element(widget).not.toBeInTheDocument();
});
it('shows the widget when topDocs is present', async () => {
render(DashboardNeedsMetadata, { topDocs: [makeDoc('d1', 'Taufschein')], totalCount: 1 });
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
});
it('renders one link per row pointing at /enrich/{id}', async () => {
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 2 });
await expect
.element(page.getByRole('link', { name: /Taufschein/ }))
.toHaveAttribute('href', '/enrich/d1');
await expect
.element(page.getByRole('link', { name: /Heiratsurkunde/ }))
.toHaveAttribute('href', '/enrich/d2');
});
it('hides the footer link when totalCount is 5 or fewer', async () => {
const docs = Array.from({ length: 5 }, (_, i) => makeDoc(`d${i}`, `Dok ${i}`));
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 5 });
const footer = page.getByRole('link', { name: /Alle/i });
await expect.element(footer).not.toBeInTheDocument();
});
it('shows the footer link with totalCount when totalCount > 5', async () => {
const docs = Array.from({ length: 5 }, (_, i) => makeDoc(`d${i}`, `Dok ${i}`));
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 12 });
const footer = page.getByRole('link', { name: /12/ });
await expect.element(footer).toHaveAttribute('href', '/enrich');
});
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
const docs = [makeDoc('d1', 'Only one')];
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { onMount } from 'svelte';
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
tags = $bindable<Tag[]>([]),
currentTitle = $bindable(''),
documentLocation = $bindable(''),
archiveBox = $bindable(''),
archiveFolder = $bindable(''),
initialTitle = '',
initialArchiveBox = '',
initialArchiveFolder = '',
initialSummary = '',
titleRequired = false,
suggestedTitle = '',
hideTitle = false,
editMode = false
}: {
tags?: Tag[];
currentTitle?: string;
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
initialTitle?: string;
initialArchiveBox?: string;
initialArchiveFolder?: string;
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
hideTitle?: boolean;
editMode?: boolean;
} = $props();
// Seed bindables from initial-* props once at mount and only when the parent
// hasn't already supplied a non-empty value through the binding. onMount runs
// exactly once per instance, so this never stomps a parent-driven update on a
// later prop change. Required by the single-doc edit flow which seeds from
// the document; bulk-edit consumers leave the initial-* unset and bind their
// own state.
let titleDirty = $state(false);
onMount(() => {
if (!currentTitle && initialTitle) currentTitle = initialTitle;
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
});
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_description()}
</h2>
<div class="space-y-5">
{#if !hideTitle}
<!-- Titel (required) -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}{#if titleRequired}
*{/if}</label
>
<input
id="title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
currentTitle = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
<!-- Optional divider -->
<div class="my-3 flex items-center gap-2">
<div class="flex-1 border-t border-line"></div>
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.label_optional()}</span
>
<div class="flex-1 border-t border-line"></div>
</div>
<!-- Schlagworte (optional) -->
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_tags()}
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
</div>
{#if !editMode}
<!-- Inhalt (optional) — not bulk-editable. -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialSummary}</textarea
>
</div>
{/if}
<!-- Karton -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input
id="archiveBox"
type="text"
name="archiveBox"
bind:value={archiveBox}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
<!-- Mappe -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input
id="archiveFolder"
type="text"
name="archiveFolder"
bind:value={archiveFolder}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import DescriptionSection from './DescriptionSection.svelte';
afterEach(() => cleanup());
describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fence)', () => {
it('pre-fills the title input from initialTitle when currentTitle is empty', async () => {
render(DescriptionSection, { initialTitle: 'Brief an Anna' });
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput).not.toBeNull();
expect(titleInput.value).toBe('Brief an Anna');
});
it('does not stomp a parent-bound currentTitle that is already non-empty', async () => {
render(DescriptionSection, {
currentTitle: 'Parent Title',
initialTitle: 'Should Not Win'
});
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput.value).toBe('Parent Title');
});
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
render(DescriptionSection, { editMode: false });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
render(DescriptionSection, { editMode: true, hideTitle: true });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
expect(input.value).toBe('K-03');
});
it('pre-fills archiveFolder from initialArchiveFolder when archiveFolder is empty', async () => {
render(DescriptionSection, { initialArchiveFolder: 'Mappe B', hideTitle: true });
const input = document.querySelector('input#archiveFolder') as HTMLInputElement;
expect(input.value).toBe('Mappe B');
});
it('does not stomp a parent-bound archiveBox that is already non-empty', async () => {
render(DescriptionSection, {
archiveBox: 'Parent Value',
initialArchiveBox: 'Should Not Win',
hideTitle: true
});
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
expect(input.value).toBe('Parent Value');
});
});

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { onMount, onDestroy, untrack } from 'svelte';
import type { Snippet } from 'svelte';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { m } from '$lib/paraglide/messages.js';
import { countRequiredFilled } from '$lib/utils/requiredFields';
import { validateFile } from '$lib/document/validateFile';
import DocumentViewer from '$lib/document/DocumentViewer.svelte';
import UploadZone from '$lib/document/UploadZone.svelte';
import WhoWhenSection from '$lib/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/document/DescriptionSection.svelte';
import type { Tag } from '$lib/components/TagInput.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type Doc = components['schemas']['Document'];
let {
doc,
formId,
formAction,
formError = null,
tags = $bindable<Tag[]>([]),
senderId = $bindable(''),
selectedReceivers = $bindable<Person[]>([]),
dateIso = $bindable(''),
currentTitle = $bindable(''),
topbar,
actionbar
}: {
doc: Doc;
formId: string;
formAction: string;
formError?: string | null;
tags?: Tag[];
senderId?: string;
selectedReceivers?: Person[];
dateIso?: string;
currentTitle?: string;
topbar: Snippet;
actionbar: Snippet;
} = $props();
tags = untrack(() => (doc.tags as Tag[]) ?? []);
senderId = untrack(() => doc.sender?.id ?? '');
selectedReceivers = untrack(() => (doc.receivers as Person[]) ?? []);
dateIso = untrack(() => doc.documentDate ?? '');
currentTitle = untrack(() => doc.title ?? '');
const fileLoader = createFileLoader();
let navHeight = $state(0);
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
});
let activeAnnotationId = $state<string | null>(null);
$effect(() => {
if (doc?.id && doc?.filePath) {
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
}
});
onDestroy(() => fileLoader.destroy());
const requiredFilled = $derived(countRequiredFilled(currentTitle, dateIso, senderId));
const requiredPct = $derived((requiredFilled / 3) * 100);
let isUploading = $state(false);
let isDragging = $state(false);
let uploadError = $state<string | null>(null);
let abortController = $state<AbortController | null>(null);
async function handleFile(file: File) {
uploadError = null;
isUploading = true;
const controller = new AbortController();
abortController = controller;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`/api/documents/${doc.id}/file`, {
method: 'POST',
body: formData,
signal: controller.signal
});
if (!res.ok) throw new Error('Upload fehlgeschlagen');
await invalidate('app:document');
} catch (e) {
if ((e as Error).name === 'AbortError') return;
uploadError = m.error_file_upload_failed();
} finally {
isUploading = false;
abortController = null;
}
}
function cancelUpload() {
abortController?.abort();
isUploading = false;
}
async function handleReplaceFile(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
const validationError = validateFile(file);
if (validationError === 'type') {
uploadError = m.error_unsupported_file_type();
return;
}
if (validationError === 'size') {
uploadError = m.error_file_too_large();
return;
}
await handleFile(file);
}
</script>
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
<!-- Top bar — caller-supplied via snippet -->
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
{@render topbar()}
</div>
<!-- Required-fields progress bar -->
<div class="flex items-center gap-3 border-b border-line bg-surface px-6 py-1.5">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.label_required_fields()}</span
>
<div
class="h-0.5 flex-1 rounded-full bg-line"
role="progressbar"
aria-valuenow={requiredFilled}
aria-valuemin={0}
aria-valuemax={3}
aria-label={m.label_required_fields()}
>
<div
class="h-full rounded-full bg-brand-navy transition-all duration-300"
style="width:{requiredPct}%"
></div>
</div>
<span class="text-xs font-bold text-brand-navy">{requiredFilled} / 3</span>
</div>
<!-- Main content -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview / upload zone (60%) -->
<div class="relative flex flex-[6] flex-col overflow-hidden border-r border-line">
{#if !doc.filePath}
<UploadZone
filename={doc.originalFilename ?? ''}
isUploading={isUploading}
bind:isDragging={isDragging}
error={uploadError}
onFile={handleFile}
onCancel={cancelUpload}
/>
{:else}
<!-- Datei ersetzen toolbar -->
<div class="flex shrink-0 items-center border-b border-line bg-surface px-4 py-1.5">
<label
class="ml-auto flex min-h-[44px] cursor-pointer items-center text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
>
{m.doc_file_replace_label()}
<input type="file" class="sr-only" onchange={handleReplaceFile} />
</label>
</div>
<div class="relative flex-1 overflow-hidden">
<DocumentViewer
doc={doc}
fileUrl={fileLoader.fileUrl}
isLoading={fileLoader.isLoading}
error={fileLoader.fileError}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={() => {}}
/>
</div>
{/if}
</div>
<!-- Right: form (40%) -->
<div class="flex flex-[4] flex-col overflow-hidden">
{#if formError}
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
{formError}
</div>
{/if}
<form
id={formId}
method="POST"
action={formAction}
enctype="multipart/form-data"
use:enhance
class="flex-1 space-y-5 overflow-y-auto p-6"
>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender?.displayName ?? ''}
/>
<DescriptionSection
bind:tags={tags}
bind:currentTitle={currentTitle}
initialTitle={doc.title ?? ''}
initialArchiveBox={doc.archiveBox ?? ''}
initialArchiveFolder={doc.archiveFolder ?? ''}
initialSummary={doc.summary ?? ''}
titleRequired={true}
/>
</form>
<!-- Action bar — caller-supplied via snippet -->
<div class="flex items-center justify-between gap-3 border-t border-line bg-surface p-4">
{@render actionbar()}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/document/documentStatusLabel';
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
documentDate: string | null;
location: string | null;
status: string;
sender: Person | null;
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
documentId?: string;
canBlogWrite?: boolean;
};
let {
documentDate,
location,
status,
sender,
receivers,
tags,
inferredRelationship = null,
geschichten = [],
documentId,
canBlogWrite = false
}: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
const VISIBLE_GESCHICHTEN_LIMIT = 3;
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
function formatGeschichteAuthor(g: GeschichteSummary): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function formatGeschichteDate(g: GeschichteSummary): string {
if (!g.publishedAt) return '';
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
const displayLocation = $derived(location ?? '—');
const statusLabel = $derived(formatDocumentStatus(status));
const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT));
const hiddenReceiverCount = $derived(Math.max(0, receivers.length - VISIBLE_RECEIVER_LIMIT));
const hasPersons = $derived(sender !== null || receivers.length > 0);
const hasTags = $derived(tags.length > 0);
let showAllReceivers = $state(false);
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
function getFullName(person: Person): string {
return person.displayName;
}
</script>
{#snippet personCard(person: Person, relationLabel: string | null = null)}
<a
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(person.id)}"
aria-hidden="true"
>
{getInitials(person.displayName)}
</span>
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
{#if relationLabel}
<RelationshipPill label={relationLabel} />
{/if}
</a>
{/snippet}
<div class="border-b border-line p-6">
<div class="grid grid-cols-1 gap-6 {gridClass}">
<!-- Column 1: Details -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_details()}
</h2>
<dl class="space-y-3 font-serif text-sm">
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
<dd class="text-ink">{formattedDate}</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>
<dd class="text-ink">{displayLocation}</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_status()}</dt>
<dd class="text-ink">{statusLabel}</dd>
</div>
</dl>
</div>
<!-- Column 2: Personen -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_persons()}
</h2>
{#if hasPersons}
<div class="space-y-3">
{#if sender}
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_sender()}
</p>
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
</div>
{/if}
{#if receivers.length > 0}
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_receivers()}
</p>
<div class="space-y-0.5">
{#each displayedReceivers as receiver, i (receiver.id)}
{@render personCard(
receiver,
// Badge only shown when there is exactly one receiver — with multiple
// receivers the inferred label is computed from the sender's viewpoint
// and cannot be attributed to a specific receiver.
i === 0 && receivers.length === 1
? (inferredRelationship?.labelFromB ?? null)
: null
)}
{/each}
</div>
{#if hiddenReceiverCount > 0 && !showAllReceivers}
<button
type="button"
onclick={() => (showAllReceivers = true)}
class="mt-1 px-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.doc_details_more_receivers({ count: hiddenReceiverCount })}
</button>
{/if}
</div>
{/if}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
{/if}
</div>
<!-- Column 3: Schlagwoerter -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_tags()}
</h2>
{#if hasTags}
<div class="flex flex-wrap gap-2">
{#each tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-accent"
>
{tag.name}
</a>
{/each}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
{/if}
</div>
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
{#if showGeschichtenColumn}
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_card_heading()}
</h2>
{#if canBlogWrite && documentId}
<a
href="/geschichten/new?documentId={documentId}"
class="font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.geschichten_card_attach_action()}
</a>
{/if}
</div>
{#if geschichten.length === 0}
<p class="font-serif text-sm text-ink-3"></p>
{:else}
<ul class="space-y-2 font-serif text-sm">
{#each visibleGeschichten as g (g.id)}
<li>
<a href="/geschichten/{g.id}" class="block text-ink hover:underline">
{g.title}
</a>
<p class="font-sans text-xs text-ink-3">
{formatGeschichteAuthor(g)}
{#if formatGeschichteDate(g)}· {formatGeschichteDate(g)}{/if}
</p>
</li>
{/each}
</ul>
{#if hasGeschichtenOverflow && documentId}
<a
href="/geschichten?documentId={documentId}"
class="mt-3 inline-flex font-sans text-xs font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all()}
</a>
{/if}
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
afterEach(cleanup);
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' };
const receivers = [
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
];
const tags = [
{ id: 't1', name: 'Familienbrief' },
{ id: 't2', name: 'Kriegszeit' }
];
function renderDrawer(overrides: Record<string, unknown> = {}) {
return render(DocumentMetadataDrawer, {
documentDate: '1942-03-15',
location: 'Berlin',
status: 'UPLOADED',
sender,
receivers,
tags,
...overrides
});
}
// ─── Details column ──────────────────────────────────────────────────────────
describe('DocumentMetadataDrawer — details column', () => {
it('renders formatted date', async () => {
renderDrawer();
await expect.element(page.getByText('15. März 1942')).toBeInTheDocument();
});
it('renders dash when date is null', async () => {
renderDrawer({ documentDate: null });
const dds = page.getByText('—');
await expect.element(dds.first()).toBeInTheDocument();
});
it('renders location', async () => {
renderDrawer();
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
it('renders dash when location is null', async () => {
renderDrawer({ location: null });
const dashes = page.getByText('—');
await expect.element(dashes.first()).toBeInTheDocument();
});
it('renders translated status label', async () => {
renderDrawer();
// "Hochgeladen" is the German translation of UPLOADED
await expect.element(page.getByText('Hochgeladen')).toBeInTheDocument();
});
});
// ─── Persons column ──────────────────────────────────────────────────────────
describe('DocumentMetadataDrawer — persons column', () => {
it('renders sender name as link to person detail', async () => {
renderDrawer();
const link = page.getByRole('link', { name: /Karl Müller/ });
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', '/persons/s1');
});
it('renders receiver names as links', async () => {
renderDrawer();
const anna = page.getByRole('link', { name: /Anna Schmidt/ });
await expect.element(anna).toHaveAttribute('href', '/persons/r1');
const hans = page.getByRole('link', { name: /Hans Weber/ });
await expect.element(hans).toHaveAttribute('href', '/persons/r2');
});
it('shows empty state when no sender and no receivers', async () => {
renderDrawer({ sender: null, receivers: [] });
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
});
it('renders inferred relationship pills inline next to sender and receiver', async () => {
renderDrawer({
receivers: [receivers[0]],
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
});
// Sender link contains its pill, receiver link contains its pill.
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
await expect.element(senderLink).toBeInTheDocument();
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
await expect.element(receiverLink).toBeInTheDocument();
});
it('omits the pills when no inferred relationship is provided', async () => {
renderDrawer();
const elternteil = page.getByText('Elternteil');
expect(await elternteil.elements()).toHaveLength(0);
});
});
// ─── Tags column ─────────────────────────────────────────────────────────────
describe('DocumentMetadataDrawer — tags column', () => {
it('renders tag chips as links', async () => {
renderDrawer();
const fb = page.getByRole('link', { name: 'Familienbrief' });
await expect.element(fb).toBeInTheDocument();
await expect.element(fb).toHaveAttribute('href', '/?tag=Familienbrief');
});
it('shows empty state when no tags', async () => {
renderDrawer({ tags: [] });
await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { formatDate } from '$lib/utils/date';
type Document = components['schemas']['Document'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
interface Props {
selectedDocuments?: Document[];
placeholder?: string;
hiddenInputName?: string;
}
let {
selectedDocuments = $bindable([]),
placeholder = m.geschichte_editor_search_document(),
hiddenInputName = 'documentIds'
}: Props = $props();
let searchTerm = $state('');
let results: Document[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentSearchItem[] } = await res.json();
const docs = body.items.map((it) => it.document);
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
}
function selectDocument(doc: Document) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
results = [];
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: Document): string {
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
return doc.title;
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedDocuments as doc (doc.id)}
<input type="hidden" name={hiddenInputName} value={doc.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
>
{#each selectedDocuments as doc (doc.id)}
<span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
>
{formatDocLabel(doc)}
<button
type="button"
onclick={() => removeDocument(doc.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
{/each}
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => {
updateDropdownPosition();
showDropdown = true;
}}
placeholder={placeholder}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as doc (doc.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectDocument(doc)}
onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}
role="button"
tabindex="0"
>
{formatDocLabel(doc)}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string, date = '1880-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentMultiSelect — rendering', () => {
it('renders an empty chip-input by default', async () => {
render(DocumentMultiSelect);
await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument();
});
it('renders pre-selected documents as chips with their date', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
});
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
});
it('emits a hidden documentIds input for each pre-selected document', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')]
});
const inputs = document.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][name="documentIds"]'
);
expect(inputs).toHaveLength(2);
expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']);
});
});
describe('DocumentMultiSelect — search and select', () => {
it('queries /api/documents/search after debounce and shows results', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringMatching(/^\/api\/documents\/search\?q=Eug/)
);
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('adds a chip when a search result is clicked', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/));
// After selection the search field clears and the chip is rendered
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="documentIds"]'
);
expect(hidden?.value).toBe('d1');
});
it('hides already-selected documents from new search results', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{ document: docFactory('d1', 'Already attached') },
{ document: docFactory('d2', 'Not attached') }
]
})
});
vi.stubGlobal('fetch', fetchMock);
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Already attached')]
});
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached');
await waitForDebounce();
// "Not attached" appears in the dropdown; "Already attached" only as the chip.
const matches = await page.getByText(/Already attached/).all();
expect(matches.length).toBe(1); // chip only, not in dropdown
await expect.element(page.getByText(/Not attached/)).toBeInTheDocument();
});
});
describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief A')]
});
await userEvent.click(page.getByLabelText('Entfernen'));
expect(
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
).toBeNull();
});
});

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { components } from '$lib/generated/api';
import { applyOffsets } from '$lib/document/search';
import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import ProgressRing from '$lib/components/ProgressRing.svelte';
import ContributorStack from '$lib/components/ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
const doc = $derived(item.document);
const titleText = $derived(doc.title || doc.originalFilename);
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));
const snippet = $derived(item.matchData?.transcriptionSnippet ?? null);
const snippetSegments = $derived(
snippet ? applyOffsets(snippet, item.matchData?.snippetOffsets ?? []) : null
);
const summary = $derived(doc.summary?.trim() ? doc.summary : null);
const summarySegments = $derived(
summary ? applyOffsets(summary, item.matchData?.summaryOffsets ?? []) : null
);
const archiveChips = $derived(
[doc.archiveBox, doc.archiveFolder, doc.location].filter(
(c): c is string => !!c && c.trim().length > 0
)
);
const senderMatched = $derived(item.matchData?.senderMatched ?? false);
const matchedReceiverIds = $derived(new Set(item.matchData?.matchedReceiverIds ?? []));
const matchedTagIds = $derived(new Set(item.matchData?.matchedTagIds ?? []));
const hasMore = $derived(item.contributors.length >= 4);
function tagClass(matched: boolean): string {
return matched
? 'pointer-events-auto inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-primary text-primary-fg transition-colors'
: 'pointer-events-auto inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-muted text-ink hover:bg-primary hover:text-primary-fg transition-colors';
}
function safeTagColor(color: string | null | undefined): string {
return color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#cdcbbf';
}
</script>
<li class="group relative transition-colors duration-200 hover:bg-muted/50">
<!--
Stretched-link pattern: the row-wide anchor sits as an overlay so it
isn't a parent of interior interactive controls (tag buttons). Nesting
<button> inside <a> is invalid HTML and was causing tag clicks to also
fire the row navigation in real browsers.
-->
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
<div class="flex gap-3 sm:gap-5">
<!-- Bulk-selection checkbox -->
{#if canWrite}
<label
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
data-testid="bulk-select-checkbox"
>
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-brand-navy"
checked={bulkSelectionStore.has(doc.id)}
onchange={() => bulkSelectionStore.toggle(doc.id)}
aria-label={m.bulk_edit_select_document({ title: titleText })}
/>
</label>
{/if}
<!-- Thumbnail tile -->
<DocumentThumbnail doc={doc} size="lg" />
<!-- Left column -->
<div class="flex-1 sm:border-r sm:border-line sm:pr-5">
<!-- Title -->
<h3 class="mb-1 font-serif text-xl font-medium text-ink group-hover:underline">
{#each titleSegments as seg, i (i)}
{#if seg.highlight}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>
{:else}
{seg.text}
{/if}
{/each}
</h3>
<!-- Snippet -->
{#if snippetSegments}
<p
data-testid="search-snippet"
class="mb-2 line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each snippetSegments as seg, i (i)}
{#if seg.highlight}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>
{:else}
{seg.text}
{/if}
{/each}
</p>
{/if}
<!-- Summary excerpt — only when populated -->
{#if summarySegments}
<p
data-testid="doc-summary"
class="mt-1 mb-2 line-clamp-2 font-serif text-sm text-ink-2 italic"
>
{#each summarySegments as seg, i (i)}
{#if seg.highlight}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>
{:else}
{seg.text}
{/if}
{/each}
</p>
{/if}
<!-- Archive metadata chips — desktop only -->
{#if archiveChips.length > 0}
<div class="mt-2 hidden flex-wrap items-center gap-1.5 sm:flex">
{#each archiveChips as chip, i (i)}
<span
class="rounded border border-line px-1.5 py-0.5 font-sans text-[10px] tracking-widest text-ink-3 uppercase"
>{chip}</span
>
{/each}
</div>
{/if}
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each doc.tags as tag (tag.id)}
<button
type="button"
class={tagClass(matchedTagIds.has(tag.id))}
onclick={() => goto('/documents?tag=' + encodeURIComponent(tag.name))}
>
{#if tag.color}
<span
class="h-1.5 w-1.5 rounded-full"
style="background-color: {safeTagColor(tag.color)};"
></span>
{/if}
{tag.name}
</button>
{/each}
</div>
{/if}
<!-- Mobile-only metadata -->
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
<div class="flex items-start gap-2">
<ProgressRing percentage={item.completionPercentage} />
<div class="flex h-9 items-center">
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
</div>
</div>
</div>
</div>
<!-- Right column — desktop only -->
<div class="hidden flex-col gap-2 pl-4 font-sans text-sm text-ink-2 sm:flex sm:w-44 lg:w-56">
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
<span class="ml-1">
{#if doc.sender}
{#if senderMatched}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
{doc.sender.displayName}
{/if}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
<span class="ml-1">
{#if doc.receivers && doc.receivers.length > 0}
{#each doc.receivers as receiver, i (receiver.id)}
{#if i > 0}<span>, </span>{/if}
{#if matchedReceiverIds.has(receiver.id)}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div class="flex items-start gap-2">
<ProgressRing percentage={item.completionPercentage} />
<div class="flex h-9 items-center">
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
</div>
</div>
</div>
</div>
</div>
</li>

View File

@@ -0,0 +1,325 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import DocumentRow from './DocumentRow.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
});
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
return {
document: {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
sender: null,
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
completionPercentage: 0,
contributors: [],
...overrides
};
}
// ─── Title ────────────────────────────────────────────────────────────────────
describe('DocumentRow title', () => {
it('renders document title', async () => {
render(DocumentRow, { item: makeItem() });
await expect.element(page.getByRole('heading', { name: 'Testbrief' })).toBeInTheDocument();
});
it('falls back to originalFilename when title is null', async () => {
const item = makeItem({ document: { ...makeItem().document, title: null } });
render(DocumentRow, { item });
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
});
it('renders a mark element for highlighted title offsets', async () => {
const item = makeItem({
document: { ...makeItem().document, title: 'Brief an Anna' },
matchData: {
titleOffsets: [{ start: 0, length: 5 }],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
});
render(DocumentRow, { item });
const mark = page.getByRole('mark');
await expect.element(mark).toBeInTheDocument();
await expect.element(mark).toHaveTextContent('Brief');
});
});
// ─── Snippet ──────────────────────────────────────────────────────────────────
describe('DocumentRow snippet', () => {
it('shows transcription snippet when present', async () => {
const item = makeItem({
matchData: {
transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
});
render(DocumentRow, { item });
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
});
it('does not render snippet section when no snippet', async () => {
render(DocumentRow, { item: makeItem() });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
});
});
// ─── Sender / receivers ───────────────────────────────────────────────────────
describe('DocumentRow sender', () => {
it('shows sender display name', async () => {
const item = makeItem({
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
}
});
render(DocumentRow, { item });
await expect.element(page.getByText('Großmutter Maria').first()).toBeInTheDocument();
});
it('shows unknown fallback when sender is null', async () => {
render(DocumentRow, { item: makeItem() });
const unknownElements = page.getByText('Unbekannt');
await expect.element(unknownElements.first()).toBeInTheDocument();
});
it('highlights the sender when senderMatched is true', async () => {
const item = makeItem({
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
},
matchData: {
...makeItem().matchData,
senderMatched: true
}
});
render(DocumentRow, { item });
const mark = page.getByRole('mark').first();
await expect.element(mark).toHaveTextContent('Großmutter Maria');
});
it('highlights a receiver when matchedReceiverIds includes its id', async () => {
const item = makeItem({
document: {
...makeItem().document,
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }]
},
matchData: {
...makeItem().matchData,
matchedReceiverIds: ['r1']
}
});
render(DocumentRow, { item });
const mark = page.getByRole('mark').first();
await expect.element(mark).toHaveTextContent('Onkel Karl');
});
});
// ─── Summary ─────────────────────────────────────────────────────────────────
describe('DocumentRow summary', () => {
it('renders the document summary when present', async () => {
const item = makeItem({
document: {
...makeItem().document,
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
}
});
render(DocumentRow, { item });
await expect
.element(page.getByTestId('doc-summary'))
.toHaveTextContent('Brief von Eugenie über die Heimreise aus dem Süden.');
});
it('does not render the summary block when summary is empty', async () => {
render(DocumentRow, { item: makeItem() });
await expect.element(page.getByTestId('doc-summary')).not.toBeInTheDocument();
});
it('applies summary search-match highlight via summaryOffsets', async () => {
const item = makeItem({
document: { ...makeItem().document, summary: 'Brief über Menton' },
matchData: {
...makeItem().matchData,
summaryOffsets: [{ start: 11, length: 6 }]
}
});
render(DocumentRow, { item });
const mark = page.getByRole('mark').first();
await expect.element(mark).toHaveTextContent('Menton');
});
});
// ─── Archive chips ───────────────────────────────────────────────────────────
describe('DocumentRow archive chips', () => {
it('renders the archive box chip when set', async () => {
const item = makeItem({
document: { ...makeItem().document, archiveBox: 'K3' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument();
});
it('renders the archive folder chip when set', async () => {
const item = makeItem({
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
});
it('renders the location chip when meta_location is set', async () => {
const item = makeItem({
document: { ...makeItem().document, location: 'Berlin' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
});
// ─── Tags ─────────────────────────────────────────────────────────────────────
describe('DocumentRow tags', () => {
it('renders tag buttons', async () => {
const item = makeItem({
document: {
...makeItem().document,
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
});
it('navigates to /documents?tag=… on tag click', async () => {
const item = makeItem({
document: {
...makeItem().document,
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
// Tailwind CSS isn't loaded in the vitest-browser client project, so the
// `z-10` that elevates the content wrapper above the stretched-link
// overlay anchor has no effect here — Playwright's coordinate-based
// click would hit the anchor instead of the tag button. Fire the click
// directly on the button to verify the handler logic.
document.querySelector<HTMLButtonElement>('button')?.click();
await expect
.poll(() => vi.mocked(goto).mock.calls[0]?.[0])
.toBe('/documents?tag=Urlaub%20%26%20Reise');
});
it('tag click does not navigate to the document detail page', async () => {
const item = makeItem({
document: {
...makeItem().document,
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
const before = window.location.href;
await page.getByRole('button', { name: 'Familie' }).click();
expect(window.location.href).toBe(before);
});
});
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
describe('DocumentRow bulk selection checkbox', () => {
it('does not render the checkbox when canWrite is false', async () => {
render(DocumentRow, { item: makeItem(), canWrite: false });
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
});
it('renders the checkbox when canWrite is true', async () => {
render(DocumentRow, { item: makeItem(), canWrite: true });
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
});
it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
render(DocumentRow, { item, canWrite: true });
await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
.toBeInTheDocument();
});
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false);
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
});
it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99');
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked();
});
});
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
describe('DocumentRow progress ring and contributors', () => {
it('renders the completion percentage label', async () => {
const item = makeItem({ completionPercentage: 42 });
render(DocumentRow, { item });
await expect.element(page.getByText('42%').first()).toBeInTheDocument();
});
it('renders contributor initials when contributors present', async () => {
const item = makeItem({
contributors: [{ initials: 'AR', color: '#4a90e2', name: 'Anna Raddatz' }]
});
render(DocumentRow, { item });
await expect.element(page.getByText('AR').first()).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { statusDotClass, statusLabel } from '$lib/utils/personFormat';
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
type Props = {
status: DocumentStatus;
};
let { status }: Props = $props();
const dotClass = $derived(statusDotClass(status));
const label = $derived(statusLabel(status));
</script>
<span
class="hidden shrink-0 md:block {dotClass} h-4 w-4 rounded-full"
title={label}
aria-label={label}
></span>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
type Doc = Pick<components['schemas']['Document'], 'id' | 'thumbnailUrl' | 'contentType'>;
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
const url = $derived(doc.thumbnailUrl ?? null);
const containerClass = $derived(
size === 'lg'
? 'relative h-[168px] w-[120px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white'
: 'relative h-[84px] w-[60px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white'
);
const iconClass = $derived(size === 'lg' ? 'h-16 w-16' : 'h-8 w-8');
</script>
<!--
Fixed-aspect (5:7, ≈A4) tile used wherever a document row appears. `sm` is
60×84 (compact rows, person sublists); `lg` is 120×168 (main document list).
When the backend has generated a thumbnail we render it with `object-cover`
+ `object-top` so letter salutations stay visible; otherwise we fall back
to the file-type icon so the row never shows an empty rectangle. Dark mode
uses `mix-blend-multiply` to keep bright paper scans from glaring against
the dark page background.
-->
<div class={containerClass}>
{#if url}
<img
src={url}
alt=""
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
loading="lazy"
decoding="async"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-ink-3" aria-hidden="true">
<!-- Generic document icon (heroicons document-text outline). Shown when the
thumbnail hasn't been generated yet — applies equally to PDFs and to
image scans, so we deliberately avoid a PDF-specific glyph here. -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class={iconClass}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
/>
</svg>
</div>
{/if}
</div>

View File

@@ -0,0 +1,303 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition';
import { formatDate } from '$lib/utils/date';
import { clickOutside } from '$lib/actions/clickOutside';
import PersonChipRow from '$lib/components/PersonChipRow.svelte';
import OverflowPillButton from '$lib/components/OverflowPillButton.svelte';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
import BackButton from '$lib/components/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));
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
let mobileMenuOpen = $state(false);
</script>
{#snippet transcribeBtn(mobile: boolean)}
<button
onclick={() => {
transcribeMode = true;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.transcription_mode_label()}
aria-pressed={false}
class={mobile
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
</button>
{/snippet}
{#snippet transcribeStopBtn(mobile: boolean)}
<button
onclick={() => {
transcribeMode = false;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.transcription_mode_stop()}
aria-pressed={true}
class={mobile
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_stop()}
</button>
{/snippet}
{#snippet downloadLink(mobile: boolean)}
<a
href={fileUrl}
download={doc.originalFilename}
onclick={() => {
if (mobile) mobileMenuOpen = false;
}}
class={mobile
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{#if mobile}{m.doc_download_title()}{/if}
</a>
{/snippet}
<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 -->
<div class="min-w-0 flex-1 overflow-hidden">
<h1
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
title={doc.title ?? doc.originalFilename ?? ''}
>
{doc.title || doc.originalFilename}
</h1>
{#if shortDate}
<p class="font-sans text-[16px] text-ink-2">
<span class="lg:hidden">{shortDate}</span>
<span class="hidden lg:inline">{longDate}</span>
</p>
{/if}
</div>
<!-- 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">
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(false)}
{/if}
{#if transcribeMode}
{@render transcribeStopBtn(false)}
{/if}
{#if canWrite && !transcribeMode}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if doc.filePath && !transcribeMode}
{@render downloadLink(false)}
{/if}
<!-- Kebab menu — mobile only, contains actions hidden below md -->
{#if (canWrite && isPdf) || doc.filePath}
<div
role="group"
class="relative md:hidden"
use:clickOutside
onclickoutside={() => (mobileMenuOpen = false)}
>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
aria-label={m.topbar_more_actions()}
aria-haspopup="true"
aria-expanded={mobileMenuOpen}
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</button>
{#if mobileMenuOpen}
<div
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(true)}
{/if}
{#if doc.filePath}
{@render downloadLink(true)}
{/if}
</div>
{/if}
</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>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PdfViewer from '$lib/components/PdfViewer.svelte';
type Doc = {
id: string;
filePath?: string | null;
contentType?: string | null;
fileHash?: string | null;
};
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
type Props = {
doc: Doc;
fileUrl: string;
isLoading: boolean;
error: string;
transcribeMode?: boolean;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
};
let {
doc,
fileUrl,
isLoading,
error,
transcribeMode = false,
blockNumbers = {},
annotationReloadKey = 0,
activeAnnotationId = $bindable(),
annotationsDimmed = false,
flashAnnotationId = null,
onAnnotationClick,
onTranscriptionDraw,
onDeleteAnnotationRequest
}: Props = $props();
</script>
<div class="absolute inset-0 bg-pdf-bg">
{#if isLoading}
<div class="flex h-full flex-col items-center justify-center text-accent">
<svg
class="mb-4 h-8 w-8 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
</div>
{:else if error}
<div class="flex h-full flex-col items-center justify-center px-4 text-center text-ink-3">
<p class="mb-2 font-serif">{error}</p>
{#if doc.filePath}
<a
href="/api/documents/{doc.id}/file"
target="_blank"
class="text-sm underline hover:text-white"
>
{m.doc_download_link()}
</a>
{/if}
</div>
{:else if !doc.filePath}
<div class="flex h-full flex-col items-center justify-center text-ink-3">
<div class="mb-6 rounded-full bg-surface/5 p-8">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-12 w-12 opacity-50 invert"
/>
</div>
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
</div>
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
<PdfViewer
url={fileUrl}
documentId={doc.id}
transcribeMode={transcribeMode}
blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey}
bind:activeAnnotationId={activeAnnotationId}
annotationsDimmed={annotationsDimmed}
flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
<img
src={fileUrl}
alt={m.doc_image_alt()}
class="max-h-full max-w-full object-contain shadow-2xl"
/>
</div>
{/if}
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { navigating } from '$app/stores';
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
type IncompleteDoc = {
id: string;
title: string;
uploadedAt: string;
};
interface Props {
topDocs: IncompleteDoc[];
totalCount: number;
bannerCount: number;
onBannerClose: () => void;
}
let { topDocs, totalCount, bannerCount, onBannerClose }: Props = $props();
const showSkeleton = $derived(!!$navigating && topDocs.length === 0);
const showBlock = $derived(topDocs.length > 0 || bannerCount > 0 || showSkeleton);
</script>
{#if showBlock}
<div data-testid="enrichment-block" class="flex flex-col gap-3">
{#if bannerCount > 0}
<UploadSuccessBanner count={bannerCount} onClose={onBannerClose} />
{/if}
{#if topDocs.length > 0}
<DashboardNeedsMetadata topDocs={topDocs} totalCount={totalCount} />
{:else if showSkeleton}
<div
data-testid="enrichment-block-skeleton"
class="h-[360px] animate-pulse rounded-sm border border-line bg-surface/50 motion-reduce:animate-none"
aria-busy="true"
aria-label="Lade aktualisierte Liste"
></div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
// The store must live in a separate module because vi.mock factories are
// hoisted and cannot reference top-level variables defined in this file.
import { navigatingStore } from './__mocks__/navigatingStore';
import EnrichmentBlock from './EnrichmentBlock.svelte';
vi.mock('$app/stores', async () => {
const mod = await import('./__mocks__/navigatingStore');
return { navigating: mod.navigatingStore };
});
afterEach(() => {
cleanup();
navigatingStore.set(null);
});
type Doc = { id: string; title: string; uploadedAt: string };
function doc(id: string, title = 'Doc'): Doc {
return { id, title, uploadedAt: '2026-04-20T12:00:00' };
}
describe('EnrichmentBlock', () => {
it('renders nothing when topDocs is empty and banner count is 0', async () => {
render(EnrichmentBlock, {
topDocs: [],
totalCount: 0,
bannerCount: 0,
onBannerClose: vi.fn()
});
await expect.element(page.getByTestId('enrichment-block')).not.toBeInTheDocument();
});
it('renders the list component when topDocs is non-empty', async () => {
render(EnrichmentBlock, {
topDocs: [doc('d1')],
totalCount: 1,
bannerCount: 0,
onBannerClose: vi.fn()
});
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
});
it('renders the banner when bannerCount > 0', async () => {
render(EnrichmentBlock, {
topDocs: [],
totalCount: 0,
bannerCount: 3,
onBannerClose: vi.fn()
});
await expect.element(page.getByRole('status')).toBeInTheDocument();
});
it('composes banner + list when both are present', async () => {
render(EnrichmentBlock, {
topDocs: [doc('d1')],
totalCount: 1,
bannerCount: 2,
onBannerClose: vi.fn()
});
await expect.element(page.getByRole('status')).toBeInTheDocument();
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
});
it('renders the skeleton when $navigating is active and topDocs is empty', async () => {
navigatingStore.set({ type: 'link' });
render(EnrichmentBlock, {
topDocs: [],
totalCount: 0,
bannerCount: 0,
onBannerClose: vi.fn()
});
await expect.element(page.getByTestId('enrichment-block-skeleton')).toBeInTheDocument();
});
it('does not render the skeleton when topDocs is non-empty even during $navigating', async () => {
navigatingStore.set({ type: 'link' });
render(EnrichmentBlock, {
topDocs: [doc('d1')],
totalCount: 1,
bannerCount: 0,
onBannerClose: vi.fn()
});
await expect.element(page.getByTestId('enrichment-block-skeleton')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { variant }: { variant: 'additive' | 'replace' } = $props();
const text = $derived(
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
);
</script>
<span
data-testid="field-label-badge-{variant}"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
>
{text}
</span>

View File

@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import FieldLabelBadge from './FieldLabelBadge.svelte';
afterEach(() => cleanup());
describe('FieldLabelBadge', () => {
it('renders the additive variant text', async () => {
render(FieldLabelBadge, { variant: 'additive' });
await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
await expect
.element(page.getByTestId('field-label-badge-additive'))
.toHaveTextContent('+ wird hinzugefügt');
});
it('renders the replace variant text', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect
.element(page.getByTestId('field-label-badge-replace'))
.toHaveTextContent('wird ersetzt');
});
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
});
});

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
export interface FileEntry {
id: string;
/** Present in upload mode only. Edit mode entries reference an existing
* document by `documentId` and have no local file blob. */
file?: File;
/** Present in edit mode only — the server-side document UUID being edited. */
documentId?: string;
title: string;
status: 'idle' | 'error';
previewUrl: string;
}
let {
files,
activeId,
onSelect,
onRemove
}: {
files: FileEntry[];
activeId: string;
onSelect: (id: string) => void;
onRemove: (id: string) => void;
} = $props();
let trackEl = $state<HTMLDivElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
function scrollPrev() {
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
}
function scrollNext() {
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
}
async function handleRemove(entry: FileEntry, index: number) {
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
onRemove(entry.id);
if (targetId) {
await tick();
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
}
}
$effect(() => {
if (!listEl) return;
const node = listEl;
function handleKeyDown(event: KeyboardEvent) {
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
if (buttons.length === 0) return;
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
if (focusedIndex === -1) return;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
const nextIndex = (focusedIndex + 1) % buttons.length;
buttons[nextIndex].focus();
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
buttons[prevIndex].focus();
}
}
node.addEventListener('keydown', handleKeyDown);
return () => node.removeEventListener('keydown', handleKeyDown);
});
</script>
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
<div
data-testid="file-switcher-strip"
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
>
<button
type="button"
aria-label={m.bulk_switcher_prev()}
onclick={scrollPrev}
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
></button
>
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
<div class="relative flex flex-1 overflow-hidden">
<div
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
></div>
<div
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
></div>
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
{#each files as entry, i (entry.id)}
<li role="listitem" class="inline-flex shrink-0 items-center">
<button
type="button"
tabindex="0"
aria-current={entry.id === activeId ? 'true' : undefined}
data-status={entry.status}
data-chip-id={entry.id}
onclick={() => onSelect(entry.id)}
class={[
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
entry.id === activeId
? 'bg-accent text-primary'
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
entry.status === 'error'
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
: ''
].join(' ')}
>
<span
class={[
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
].join(' ')}
>{i + 1}</span
>
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
{#if entry.status === 'error'}
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
{/if}
</button>
<button
type="button"
aria-label={m.bulk_remove_file()}
data-remove-id={entry.id}
onclick={() => handleRemove(entry, i)}
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
×
</button>
</li>
{/each}
</ul>
</div>
</div>
<button
type="button"
aria-label={m.bulk_switcher_next()}
onclick={scrollNext}
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
></button
>
</div>

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
afterEach(cleanup);
function makeFiles(n: number): FileEntry[] {
return Array.from({ length: n }, (_, i) => ({
id: `id-${i}`,
file: new File([''], `file${i}.pdf`),
title: `File ${i}`,
status: 'idle' as const,
previewUrl: ''
}));
}
describe('FileSwitcherStrip', () => {
it('renders N chips for N files', async () => {
const files = makeFiles(4);
render(FileSwitcherStrip, {
files,
activeId: files[0].id,
onSelect: vi.fn(),
onRemove: vi.fn()
});
const chips = page.getByRole('listitem');
await expect.element(chips.nth(0)).toBeInTheDocument();
await expect.element(chips.nth(3)).toBeInTheDocument();
});
it('active chip has aria-current="true"', async () => {
const files = makeFiles(3);
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[1].id,
onSelect: vi.fn(),
onRemove: vi.fn()
});
const activeBtn = container.querySelector('[aria-current="true"]');
expect(activeBtn).not.toBeNull();
expect(activeBtn?.textContent).toContain('File 1');
});
it('clicking a chip fires onSelect with its id', async () => {
const files = makeFiles(3);
const onSelect = vi.fn();
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[0].id,
onSelect,
onRemove: vi.fn()
});
const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement;
expect(chip).not.toBeNull();
chip.click();
expect(onSelect).toHaveBeenCalledWith('id-2');
});
it('error chip has aria-label containing warning indicator', async () => {
const files: FileEntry[] = [
{
id: 'e1',
file: new File([''], 'bad.pdf'),
title: 'Bad file',
status: 'error',
previewUrl: ''
}
];
const { container } = render(FileSwitcherStrip, {
files,
activeId: 'e1',
onSelect: vi.fn(),
onRemove: vi.fn()
});
const errBtn = container.querySelector('[data-status="error"]');
expect(errBtn).not.toBeNull();
});
it('error chip contains a screen-reader-only error label', async () => {
const files: FileEntry[] = [
{
id: 'e1',
file: new File([''], 'bad.pdf'),
title: 'Bad file',
status: 'error',
previewUrl: ''
}
];
const { container } = render(FileSwitcherStrip, {
files,
activeId: 'e1',
onSelect: vi.fn(),
onRemove: vi.fn()
});
const errBtn = container.querySelector('[data-status="error"]');
const srOnly = errBtn?.querySelector('.sr-only');
expect(srOnly).not.toBeNull();
});
it('focus moves to the previous chip after the middle chip is removed', async () => {
const files = makeFiles(3); // id-0, id-1, id-2
const onRemove = vi.fn();
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[1].id,
onSelect: vi.fn(),
onRemove
});
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
expect(removeBtn).not.toBeNull();
removeBtn.click();
expect(onRemove).toHaveBeenCalledWith('id-1');
// After removal, focus should be on the chip for id-0 (the previous chip)
await vi.waitFor(
() => {
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
expect(prevChip).not.toBeNull();
expect(document.activeElement).toBe(prevChip);
},
{ timeout: 1000 }
);
});
it('ArrowRight moves focus to next chip without leaving strip', async () => {
const files = makeFiles(3);
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[0].id,
onSelect: vi.fn(),
onRemove: vi.fn()
});
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
firstBtn.focus();
await userEvent.keyboard('{ArrowRight}');
const focused = document.activeElement;
expect(focused).not.toBe(firstBtn);
// The new focused element should still be inside the strip
const strip = container.querySelector('[data-testid="file-switcher-strip"]');
expect(strip?.contains(focused)).toBe(true);
});
});

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
import ContributorStack from '$lib/components/ContributorStack.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
interface Props {
docs: TranscriptionQueueItemDTO[];
}
let { docs }: Props = $props();
function reviewedPct(doc: TranscriptionQueueItemDTO): number {
if (doc.annotationCount === 0) return 0;
return Math.round((doc.reviewedBlockCount / doc.annotationCount) * 100);
}
</script>
{#if docs.length > 0}
<div
class="flex flex-col gap-3 rounded-sm border border-brand-mint bg-brand-mint/10 p-4 transition-shadow hover:shadow-sm"
>
<div>
<div class="mb-1">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_ready_heading()}
</h3>
</div>
<p class="text-xs font-semibold text-ink-2">
{m.mission_control_ready_subtitle({ count: docs.length })}
</p>
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-brand-mint/20 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
<div class="mt-0.5 flex items-center gap-2">
{#if doc.documentDate}
<span class="text-xs text-ink-3">{formatMCDate(doc.documentDate, getLocale())}</span
>
{/if}
{#if doc.textedBlockCount > 0}
<span class="text-xs font-semibold text-ink">
{m.mission_control_reviewed_pct({ pct: reviewedPct(doc) })}
</span>
{/if}
</div>
<div class="mt-1">
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
</div>
</a>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-brand-mint bg-brand-mint/5 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_ready_empty()}</p>
<a
href="/enrich?filter=NEEDS_SEGMENTATION&next=1"
class="mt-2 inline-flex items-center rounded-sm border border-ink px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-ink hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
{m.mission_control_ready_empty_cta()}
</a>
</div>
{/if}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReadyColumn from './ReadyColumn.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
afterEach(cleanup);
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
return {
id: 'doc-1',
title: 'Test Dokument',
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}
describe('ReadyColumn', () => {
it('renders mint-themed list when docs are provided', async () => {
const doc1 = makeDoc({ id: 'doc-1', title: 'Leseферtig Brief' });
const doc2 = makeDoc({ id: 'doc-2', title: 'Archiv Dokument' });
render(ReadyColumn, { props: { docs: [doc1, doc2] } });
await expect.element(page.getByText('Leseферtig Brief')).toBeInTheDocument();
await expect.element(page.getByText('Archiv Dokument')).toBeInTheDocument();
// Mint-themed container should exist
const mintContainer = document.querySelector('.border-brand-mint');
expect(mintContainer).not.toBeNull();
});
it('renders dashed empty state with CTA link when docs array is empty', async () => {
render(ReadyColumn, { props: { docs: [] } });
await expect
.element(page.getByText('Noch keine Dokumente vollständig transkribiert.'))
.toBeInTheDocument();
const ctaLink = page.getByRole('link', { name: 'Jetzt mitmachen' });
await expect.element(ctaLink).toBeInTheDocument();
await expect
.element(ctaLink)
.toHaveAttribute('href', '/enrich?filter=NEEDS_SEGMENTATION&next=1');
});
it('shows reviewedPct using annotationCount as denominator', async () => {
// annotationCount=4, reviewedBlockCount=4, textedBlockCount=2
// reviewedPct = Math.round(4 / 4 * 100) = 100, NOT Math.round(4/2*100) = 200
const doc = makeDoc({
id: 'doc-1',
title: 'Geprüftes Dokument',
annotationCount: 4,
reviewedBlockCount: 4,
textedBlockCount: 2
});
render(ReadyColumn, { props: { docs: [doc] } });
// Should show 100% (using annotationCount=4 as denominator)
await expect.element(page.getByText('100% geprüft')).toBeInTheDocument();
});
it('links to /documents/{id}', async () => {
const doc = makeDoc({ id: 'ready-789', title: 'Fertiges Dokument' });
render(ReadyColumn, { props: { docs: [doc] } });
const link = page.getByRole('link', { name: /Fertiges Dokument/ });
await expect.element(link).toHaveAttribute('href', '/documents/ready-789');
});
});

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
variant,
count = 0,
children
}: {
variant: 'per-file' | 'shared';
count?: number;
children?: import('svelte').Snippet;
} = $props();
</script>
<div
data-testid="scope-card"
data-variant={variant}
class="mb-3 rounded-sm border p-4
{variant === 'per-file'
? 'border-accent bg-accent-bg'
: 'border-line bg-surface'}"
>
{#if variant === 'shared'}
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.bulk_scope_shared_label({ count })}
</span>
<span
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
>
{count}
</span>
</div>
{:else}
<p class="mb-3 text-xs font-bold tracking-widest text-primary uppercase">
{m.bulk_scope_per_file_label()}
</p>
{/if}
{@render children?.()}
</div>

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ScopeCard from './ScopeCard.svelte';
afterEach(cleanup);
describe('ScopeCard', () => {
it('per-file variant has accent background class', async () => {
const { container } = render(ScopeCard, { variant: 'per-file', count: 1 });
const card = container.querySelector('[data-testid="scope-card"]');
expect(card?.className).toMatch(/bg-accent-bg/);
});
it('shared variant does not have accent background', async () => {
const { container } = render(ScopeCard, { variant: 'shared', count: 3 });
const card = container.querySelector('[data-testid="scope-card"]');
expect(card?.className).not.toMatch(/bg-accent-bg/);
});
it('shared variant renders count badge with file count', async () => {
render(ScopeCard, { variant: 'shared', count: 5 });
await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument();
});
it('per-file variant renders slot content', async () => {
// ScopeCard is a container — verify it renders children
render(ScopeCard, { variant: 'per-file', count: 1 });
const card = await page.getByTestId('scope-card');
await expect.element(card).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<div>
<label for="script-type-select" class="text-sm font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_trigger_label()}
</label>
<select
id="script-type-select"
bind:value={value}
disabled={disabled}
class="border-brand-sand min-h-[44px] w-full rounded-sm border bg-white px-3 py-2 font-serif text-sm text-brand-navy focus:ring-2 focus:ring-brand-mint focus:outline-none"
>
<option value="" disabled>{m.ocr_trigger_select_placeholder()}</option>
<option value="TYPEWRITER">{m.ocr_script_type_typewriter()}</option>
<option value="HANDWRITING_LATIN">{m.ocr_script_type_handwriting_latin()}</option>
<option value="HANDWRITING_KURRENT">{m.ocr_script_type_handwriting_kurrent()}</option>
</select>
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
import ContributorStack from '$lib/components/ContributorStack.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
</script>
{#if docs.length > 0}
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
<div>
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_segmentation_heading()}
</h3>
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink"
>
{m.mission_control_seg_skill_pill()}
</span>
{#if weeklyCount > 0}
<p class="mt-1 text-xs font-semibold text-ink-2">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</p>
{/if}
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}?task=transcribe"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3"
>{formatMCDate(doc.documentDate, getLocale())}</span
>
{/if}
<div class="mt-1">
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
</div>
</a>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface/50 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_segmentation_empty()}</p>
</div>
{/if}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SegmentationColumn from './SegmentationColumn.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
afterEach(cleanup);
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
return {
id: 'doc-1',
title: 'Test Dokument',
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}
describe('SegmentationColumn', () => {
it('renders document list when docs are provided', async () => {
const doc1 = makeDoc({ id: 'doc-1', title: 'Brief an Maria' });
const doc2 = makeDoc({ id: 'doc-2', title: 'Postkarte 1923' });
render(SegmentationColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } });
await expect.element(page.getByText('Brief an Maria')).toBeInTheDocument();
await expect.element(page.getByText('Postkarte 1923')).toBeInTheDocument();
});
it('renders dashed empty state when docs array is empty', async () => {
render(SegmentationColumn, { props: { docs: [], weeklyCount: 0 } });
await expect
.element(page.getByText('Alle Dokumente haben bereits Segmentierungsblöcke.'))
.toBeInTheDocument();
});
it('shows weekly pulse when weeklyCount > 0', async () => {
const doc = makeDoc({ id: 'doc-1', title: 'Brief' });
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 3 } });
await expect.element(page.getByText(/\+3 diese Woche/)).toBeInTheDocument();
});
it('does not show weekly pulse when weeklyCount is 0', async () => {
const doc = makeDoc({ id: 'doc-1', title: 'Brief' });
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument();
});
it('links to /documents/{id}?task=transcribe', async () => {
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
await expect.element(link).toHaveAttribute('href', '/documents/abc-123?task=transcribe');
});
});

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import ConversationThumbnail from '$lib/conversation/ConversationThumbnail.svelte';
import TagChipList from '$lib/components/TagChipList.svelte';
import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Doc = {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
summary?: string;
contentType?: string;
thumbnailKey?: string;
thumbnailGeneratedAt?: string;
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
pageCount?: number;
sender?: Person | null;
receivers?: Person[];
tags?: Tag[];
};
let {
doc,
isOut,
showOtherParty
}: {
doc: Doc;
isOut: boolean;
showOtherParty: boolean;
} = $props();
const title = $derived(doc.title || doc.originalFilename);
const otherPartyName = $derived(
showOtherParty
? isOut
? (doc.receivers?.[0]?.displayName ?? '')
: (doc.sender?.displayName ?? '')
: ''
);
const directionLabel = $derived(isOut ? m.row_direction_sent() : m.row_direction_received());
const ariaLabel = $derived(
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
);
</script>
<a
href={`/documents/${doc.id}`}
aria-label={ariaLabel}
class="group flex min-h-[120px] items-start gap-3 border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 focus-within:bg-muted hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
class:border-l-primary={isOut}
class:border-l-accent={!isOut}
>
<ConversationThumbnail doc={doc} />
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="flex min-w-0 items-center gap-2">
<img
data-testid="thumb-row-direction-icon"
src={isOut
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 opacity-70"
class:text-primary={isOut}
class:text-accent={!isOut}
/>
<div class="min-w-0 flex-1 truncate text-lg font-bold text-ink">
{title}
</div>
</div>
{#if doc.summary}
<div class="line-clamp-2 text-base text-ink-2 italic">
&ldquo;{doc.summary}&rdquo;
</div>
{/if}
<div class="flex flex-wrap items-center gap-x-[6px] gap-y-1 text-sm text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
{#if doc.location}
<span class="text-line">·</span>
<span>{doc.location}</span>
{/if}
{#if otherPartyName}
<span class="text-line">·</span>
<span>{otherPartyName}</span>
{/if}
</div>
<TagChipList tags={doc.tags ?? []} />
</div>
</a>

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import ThumbnailRow from './ThumbnailRow.svelte';
afterEach(() => {
cleanup();
});
const baseDoc = {
id: 'd1',
title: 'Liebe Anna',
originalFilename: 'liebe_anna.pdf',
documentDate: '1950-06-01',
location: 'Berlin',
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
contentType: 'application/pdf',
thumbnailKey: 'thumbnails/d1.jpg',
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
thumbnailAspect: 'PORTRAIT' as const,
pageCount: 2,
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
tags: [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Krieg' },
{ id: 't3', name: 'Reise' },
{ id: 't4', name: 'Arbeit' },
{ id: 't5', name: 'Zuhause' }
]
};
describe('ThumbnailRow', () => {
it('renders the title, date, location, and summary quote', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).toContain('Liebe Anna');
expect(document.body.textContent).toContain('Berlin');
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
});
it('falls back to originalFilename when title is empty', () => {
render(ThumbnailRow, {
doc: { ...baseDoc, title: '' },
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).toContain('liebe_anna.pdf');
});
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: true
});
// Out-going from Hans, other party is first receiver (Anna Schmidt)
expect(document.body.textContent).toContain('Anna Schmidt');
});
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
// Anna is the receiver; in a bilateral list we suppress party names.
expect(document.body.textContent).not.toContain('Anna Schmidt');
});
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips.length).toBeLessThanOrEqual(3);
expect(document.body.textContent).toMatch(/\+2/);
});
it('does not render a relative-year label', () => {
// Document date is historical; we deliberately omit the "vor N Jahren"
// chip so the row can give vertical space to the title + summary.
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/);
expect(document.body.textContent).not.toMatch(/vor weniger/);
});
it('renders the title at text-lg so the row uses its full vertical space', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const titleEl = [...document.querySelectorAll('div')].find(
(el) => el.textContent?.trim() === 'Liebe Anna' && el.className.includes('truncate')
) as HTMLElement | undefined;
expect(titleEl, 'title element not found').toBeDefined();
expect(titleEl!.className).toContain('text-lg');
});
it('renders a right-arrow icon for outgoing letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const arrow = document.querySelector(
'[data-testid="thumb-row-direction-icon"]'
) as HTMLImageElement | null;
expect(arrow).not.toBeNull();
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Right/);
// Decorative — direction is already announced via the aria-label prefix.
expect(arrow!.getAttribute('aria-hidden')).toBe('true');
});
it('renders a left-arrow icon for incoming letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
const arrow = document.querySelector(
'[data-testid="thumb-row-direction-icon"]'
) as HTMLImageElement | null;
expect(arrow).not.toBeNull();
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Left/);
});
it('sets border-l class based on isOut', () => {
const { unmount } = render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.className).toContain('border-l-primary');
unmount();
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.className).toContain('border-l-accent');
});
it('exposes a descriptive aria-label combining direction, title, and date', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
// Direction label routes through Paraglide so EN / ES users don't hear
// "Gesendet" in their screen reader.
expect(label.startsWith(`${m.row_direction_sent()}:`)).toBe(true);
expect(label).toContain('Liebe Anna');
expect(label).toMatch(/1950/);
});
it('aria-label leads with the received direction label for incoming letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
expect(label.startsWith(`${m.row_direction_received()}:`)).toBe(true);
});
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
render(ThumbnailRow, {
doc: {
...baseDoc,
summary: 'safe <img src=x onerror="alert(1)"> text'
},
isOut: true,
showOtherParty: false
});
// No real img tag from the summary, the ConversationThumbnail img is fine.
const imgs = document.querySelectorAll('img[onerror]');
expect(imgs.length).toBe(0);
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
});
it('handles missing optional fields without crashing', () => {
render(ThumbnailRow, {
doc: {
id: 'n1',
title: 'Ohne Datum',
originalFilename: 'x.pdf',
contentType: 'application/pdf',
thumbnailAspect: 'PORTRAIT'
},
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).toContain('Ohne Datum');
});
});

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
fileCount,
chunkProgress,
onSave,
onDiscard,
disabled = false,
editMode = false
}: {
fileCount: number;
chunkProgress?: { done: number; total: number };
onSave: () => void;
onDiscard: () => void | Promise<void>;
disabled?: boolean;
editMode?: boolean;
} = $props();
const saveCta = $derived.by(() => {
if (editMode) return m.bulk_edit_save_button();
return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount });
});
</script>
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
{#if chunkProgress}
<progress
value={chunkProgress.done}
max={chunkProgress.total}
aria-valuenow={chunkProgress.done}
aria-valuemin={0}
aria-valuemax={chunkProgress.total}
aria-label={editMode
? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })
: m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
></progress>
{#if editMode && chunkProgress.total > 1}
<!-- Visible progress text for sighted users on multi-chunk PATCH
(Elicit S3 — the unitless bar isn't enough for a 30-second op). -->
<p
class="mb-2 font-sans text-xs text-ink-2"
aria-live="polite"
data-testid="bulk-edit-chunk-progress-text"
>
{m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })}
</p>
{/if}
{/if}
<div class="flex items-center justify-between gap-3">
<button
type="button"
onclick={onDiscard}
class="flex min-h-[44px] items-center px-2 text-sm text-red-600/70 hover:text-red-700"
>
{m.bulk_discard_all()}
</button>
<button
type="button"
data-testid="bulk-save-btn"
disabled={fileCount === 0 || disabled}
onclick={onSave}
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
>
{saveCta}
</button>
</div>
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadSaveBar from './UploadSaveBar.svelte';
afterEach(cleanup);
describe('UploadSaveBar', () => {
it('shows plural label for multiple files', async () => {
render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() });
// "5 speichern →" or similar plural form
await expect.element(page.getByText(/5/)).toBeInTheDocument();
});
it('shows singular label for one file', async () => {
render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() });
// "Speichern →" singular form
await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument();
});
it('progress bar is visible when chunkProgress is provided', async () => {
const { container } = render(UploadSaveBar, {
fileCount: 3,
chunkProgress: { done: 1, total: 3 },
onSave: vi.fn(),
onDiscard: vi.fn()
});
const progress = container.querySelector('progress');
expect(progress).not.toBeNull();
expect(progress?.getAttribute('value')).toBe('1');
expect(progress?.getAttribute('max')).toBe('3');
});
it('progress bar is not rendered when no chunkProgress', async () => {
const { container } = render(UploadSaveBar, {
fileCount: 2,
onSave: vi.fn(),
onDiscard: vi.fn()
});
const progress = container.querySelector('progress');
expect(progress).toBeNull();
});
it('discard link is rendered', async () => {
render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() });
await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
count: number;
onClose: () => void;
}
let { count, onClose }: Props = $props();
const message = $derived(
count === 1 ? m.upload_banner_singular() : m.upload_banner_plural({ count })
);
$effect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
});
</script>
<div
role="status"
aria-live="polite"
class="flex items-center gap-3 rounded-sm border border-line bg-accent-bg/60 px-4 py-3 text-ink"
>
<svg
class="h-5 w-5 shrink-0 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p class="flex-1 font-sans text-sm">
<span>{message}</span>
<a href="/enrich" class="ml-1 font-medium text-primary hover:underline">
{m.upload_banner_cta()}
</a>
</p>
<button
type="button"
data-testid="upload-banner-close"
aria-label={m.upload_banner_close()}
onclick={onClose}
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-ink/10 hover:text-ink"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('UploadSuccessBanner', () => {
it('renders singular copy for count of 1', async () => {
render(UploadSuccessBanner, { count: 1, onClose: () => {} });
const status = page.getByRole('status');
await expect.element(status).toBeInTheDocument();
await expect.element(status).toHaveTextContent(/1 Dokument/);
});
it('renders plural copy for count greater than 1', async () => {
render(UploadSuccessBanner, { count: 3, onClose: () => {} });
await expect.element(page.getByRole('status')).toHaveTextContent(/3 Dokumente/);
});
it('exposes role=status with aria-live polite', async () => {
render(UploadSuccessBanner, { count: 1, onClose: () => {} });
await expect.element(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
});
it('renders a CTA link to /enrich', async () => {
render(UploadSuccessBanner, { count: 2, onClose: () => {} });
await expect
.element(page.getByRole('link', { name: /ergänzen/i }))
.toHaveAttribute('href', '/enrich');
});
it('invokes onClose when the close button is clicked', async () => {
const onClose = vi.fn();
render(UploadSuccessBanner, { count: 1, onClose });
const button = document.querySelector(
'[data-testid="upload-banner-close"]'
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
button?.click();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('auto-dismisses after 8000ms', async () => {
vi.useFakeTimers();
const onClose = vi.fn();
render(UploadSuccessBanner, { count: 1, onClose });
vi.advanceTimersByTime(7999);
expect(onClose).not.toHaveBeenCalled();
vi.advanceTimersByTime(2);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,115 @@
<script lang="ts">
const ALLOWED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']);
const MAX_SIZE_BYTES = 50 * 1024 * 1024;
let {
filename,
isUploading,
isDragging = $bindable(false),
error,
onFile,
onCancel
}: {
filename: string;
isUploading: boolean;
isDragging?: boolean;
error: string | null;
onFile?: (file: File) => void;
onCancel?: () => void;
} = $props();
let validationError = $state<string | null>(null);
const displayError = $derived(error ?? validationError);
function handleFile(file: File | undefined) {
if (!file) return;
validationError = null;
if (!ALLOWED_TYPES.has(file.type)) {
validationError = 'Dieser Dateityp wird nicht unterstützt (PDF, JPG, PNG, TIFF).';
return;
}
if (file.size > MAX_SIZE_BYTES) {
validationError = 'Die Datei ist zu groß (max. 50 MB).';
return;
}
onFile?.(file);
}
function handleChange(e: Event) {
const input = e.currentTarget as HTMLInputElement;
handleFile(input.files?.[0]);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
handleFile(e.dataTransfer?.files[0]);
}
</script>
<div
class="flex flex-1 items-center justify-center bg-pdf-bg"
aria-live="polite"
aria-label={isUploading ? 'Datei wird hochgeladen' : 'Dateiupload-Bereich'}
>
{#if isUploading}
<div role="status" class="flex flex-col items-center gap-3 text-center">
<div class="h-0.5 w-48 overflow-hidden rounded-full bg-white/10">
<div
class="h-full bg-brand-mint/70 motion-safe:animate-[slide_1.4s_ease-in-out_infinite]"
></div>
</div>
<p class="max-w-[200px] truncate text-xs font-medium text-brand-mint/70">{filename}</p>
<p class="text-xs text-white/40">Wird hochgeladen …</p>
<button
class="min-h-[44px] px-3 text-xs text-white/40 transition-colors hover:text-white/60"
onclick={onCancel}
>
Abbrechen
</button>
</div>
{:else}
<div
role="region"
aria-label="Datei ablegen"
class="flex flex-col items-center gap-3 rounded-sm border border-dashed p-8 text-center transition-colors
{isDragging ? 'border-brand-mint bg-brand-mint/5' : 'border-white/20'}"
ondragover={(e) => {
e.preventDefault();
isDragging = true;
}}
ondragleave={() => (isDragging = false)}
ondrop={handleDrop}
>
<div
class="upload-zone-icon flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/40"
>
<svg
width="16"
height="16"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<polygon
fill="currentColor"
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
/>
</svg>
</div>
<p class="max-w-[200px] truncate text-xs font-medium text-white/50">{filename}</p>
<p class="text-xs text-white/30">Noch keine Datei hochgeladen</p>
{#if displayError}
<p class="text-xs text-red-400">{displayError}</p>
{/if}
<label
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-brand-navy px-4 py-1.5 text-xs font-bold tracking-widest text-white/90 uppercase"
aria-label="Datei auswählen"
>
Datei auswählen
<input type="file" class="sr-only" onchange={handleChange} />
</label>
<p class="text-xs text-white/20">oder Datei hier ablegen</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadZone from './UploadZone.svelte';
afterEach(cleanup);
describe('UploadZone', () => {
it('renders an SVG arrow icon (not a Unicode character) in idle state', async () => {
const { container } = render(UploadZone, {
filename: 'test.pdf',
isUploading: false,
error: null
});
// The icon must be an SVG element, not the raw "↑" text
const svg = container.querySelector('.upload-zone-icon svg');
expect(svg).not.toBeNull();
expect(container.textContent).not.toContain('↑');
});
it('shows the filename in idle state', async () => {
render(UploadZone, { filename: 'my-document.pdf', isUploading: false, error: null });
await expect.element(page.getByText('my-document.pdf')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadZone from './UploadZone.svelte';
describe('UploadZone', () => {
describe('idle state', () => {
it('shows the filename in the upload zone', async () => {
render(UploadZone, {
props: { filename: 'brief_1920.pdf', isUploading: false, isDragging: false, error: null }
});
await expect.element(page.getByText('brief_1920.pdf')).toBeVisible();
});
it('shows "Datei auswählen" button', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null }
});
await expect.element(page.getByText('Datei auswählen')).toBeVisible();
});
it('does not show the uploading animation', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null }
});
expect(document.querySelector('[role="status"]')).toBeNull();
});
});
describe('uploading state', () => {
it('shows the uploading progress region', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null }
});
await expect.element(page.getByRole('status')).toBeVisible();
});
it('shows Abbrechen button during upload', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null }
});
await expect.element(page.getByText('Abbrechen')).toBeVisible();
});
it('calls onCancel when Abbrechen is clicked', async () => {
const onCancel = vi.fn();
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null, onCancel }
});
// Click the button inside [role="status"] — more specific than querySelector('button')
const btn = document.querySelector('[role="status"] button') as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onCancel).toHaveBeenCalledOnce();
});
});
describe('error state', () => {
it('shows the error message', async () => {
render(UploadZone, {
props: {
filename: 'scan.pdf',
isUploading: false,
isDragging: false,
error: 'Dateityp nicht unterstützt'
}
});
await expect.element(page.getByText('Dateityp nicht unterstützt')).toBeVisible();
});
});
describe('file selection', () => {
it('calls onFile for a valid PDF', () => {
const onFile = vi.fn();
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
});
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const pdf = new File(['%PDF-1.4'], 'brief.pdf', { type: 'application/pdf' });
Object.defineProperty(input, 'files', { value: [pdf], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
expect(onFile).toHaveBeenCalledWith(pdf);
});
it('does not call onFile for an unsupported MIME type', async () => {
const onFile = vi.fn();
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
});
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const docxFile = new File(['x'], 'test.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
Object.defineProperty(input, 'files', { value: [docxFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
expect(onFile).not.toHaveBeenCalled();
await expect
.element(page.getByText('Dieser Dateityp wird nicht unterstützt (PDF, JPG, PNG, TIFF).'))
.toBeVisible();
});
it('does not call onFile when file exceeds 50 MB', async () => {
const onFile = vi.fn();
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
});
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const bigFile = new File(['x'.repeat(1)], 'huge.pdf', { type: 'application/pdf' });
Object.defineProperty(bigFile, 'size', { value: 51 * 1024 * 1024 });
Object.defineProperty(input, 'files', { value: [bigFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
expect(onFile).not.toHaveBeenCalled();
await expect.element(page.getByText('Die Datei ist zu groß (max. 50 MB).')).toBeVisible();
});
});
});

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
let {
senderId = $bindable(''),
selectedReceivers = $bindable<Person[]>([]),
dateIso = $bindable(''),
initialDateIso = '',
initialLocation = '',
initialSenderName = '',
suggestedDateIso = '',
suggestedSenderName = '',
hideDate = false,
editMode = false
}: {
senderId?: string;
selectedReceivers?: Person[];
dateIso?: string;
initialDateIso?: string;
initialLocation?: string;
initialSenderName?: string;
suggestedDateIso?: string;
suggestedSenderName?: string;
hideDate?: boolean;
editMode?: boolean;
} = $props();
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
// and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false);
onMount(() => {
const seed = dateIso || initialDateIso;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {
const result = handleGermanDateInput(e);
dateDisplay = result.display;
dateIso = result.iso;
dateDirty = true;
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
dateDisplay = isoToGerman(suggested);
dateIso = suggested;
}
});
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_who_when()}
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
{#if !hideDate}
<!-- Datum (required — row 1, col 1) -->
<div data-testid="who-when-date">
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_date()}*</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
{/if}
<!-- Absender (required in upload mode — row 1, col 2) -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
required={!editMode}
bind:value={senderId}
initialName={initialSenderName}
suggestedName={suggestedSenderName}
badge={editMode ? 'replace' : undefined}
/>
</div>
<!-- Empfänger (optional — row 2, col 1) -->
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_receivers()}
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
{#if !editMode}
<!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
NOT bulk-editable per the issue spec; the three editable location
fields live in DescriptionSection. -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import WhoWhenSection from './WhoWhenSection.svelte';
afterEach(() => cleanup());
describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', () => {
it('pre-fills the date input from initialDateIso when the bindable is empty', async () => {
render(WhoWhenSection, { initialDateIso: '2024-03-15' });
// isoToGerman('2024-03-15') → '15.03.2024'
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput).not.toBeNull();
expect(dateInput.value).toBe('15.03.2024');
});
it('does not stomp a parent-bound dateIso that is already non-empty', async () => {
// dateIso bindable is '2026-01-01' from the parent; initialDateIso is the
// "fallback seed". onMount must not overwrite the already-bound value.
render(WhoWhenSection, { dateIso: '2026-01-01', initialDateIso: '1900-01-01' });
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput.value).toBe('01.01.2026');
});
it('hides the date field when hideDate=true (bulk-edit mode)', async () => {
render(WhoWhenSection, { hideDate: true });
await expect.element(page.getByTestId('who-when-date')).not.toBeInTheDocument();
});
it('renders the meta_location input only outside editMode', async () => {
const { rerender } = render(WhoWhenSection, { editMode: true });
expect(document.querySelector('input#location')).toBeNull();
await rerender({ editMode: false });
expect(document.querySelector('input#location')).not.toBeNull();
});
it('pre-fills the location input from initialLocation', async () => {
render(WhoWhenSection, { editMode: false, initialLocation: 'Berlin' });
const locationInput = document.querySelector('input#location') as HTMLInputElement;
expect(locationInput.value).toBe('Berlin');
});
});

View File

@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const navigatingStore = writable<unknown | null>(null);

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from 'vitest';
import { bulkSelectionStore } from './bulkSelection.svelte';
describe('bulkSelectionStore', () => {
afterEach(() => bulkSelectionStore.clear());
it('starts empty', () => {
expect(bulkSelectionStore.size).toBe(0);
});
it('toggle adds an id when absent', () => {
bulkSelectionStore.toggle('a');
expect(bulkSelectionStore.has('a')).toBe(true);
expect(bulkSelectionStore.size).toBe(1);
});
it('toggle removes an id when present', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.toggle('a');
expect(bulkSelectionStore.has('a')).toBe(false);
});
it('add and remove update size', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
expect(bulkSelectionStore.size).toBe(2);
bulkSelectionStore.remove('a');
expect(bulkSelectionStore.size).toBe(1);
expect(bulkSelectionStore.has('b')).toBe(true);
});
it('add is idempotent', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('a');
expect(bulkSelectionStore.size).toBe(1);
});
it('setAll replaces the selection', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd', 'e']);
expect(bulkSelectionStore.size).toBe(3);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
});
it('clear empties the selection', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.clear();
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll([]) leaves the store empty (no-op when filter returned zero matches)', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.setAll([]);
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll drops all previously selected ids, not just some', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd']);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('b')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
expect(bulkSelectionStore.has('d')).toBe(true);
});
it('ids getter exposes the current SvelteSet', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
const ids = Array.from(bulkSelectionStore.ids);
expect(ids.sort()).toEqual(['a', 'b']);
});
});

View File

@@ -0,0 +1,36 @@
import { SvelteSet } from 'svelte/reactivity';
// Live accumulator. Selection persists across pagination and route changes
// within /documents and /enrich. Cleared on successful bulk save or via
// "Alles aufheben". The store is module-singleton — there is only ever one
// bulk-edit selection per browser session.
const selectedIds = new SvelteSet<string>();
export const bulkSelectionStore = {
get ids(): SvelteSet<string> {
return selectedIds;
},
get size(): number {
return selectedIds.size;
},
has(id: string): boolean {
return selectedIds.has(id);
},
toggle(id: string): void {
if (selectedIds.has(id)) selectedIds.delete(id);
else selectedIds.add(id);
},
add(id: string): void {
selectedIds.add(id);
},
remove(id: string): void {
selectedIds.delete(id);
},
setAll(ids: Iterable<string>): void {
selectedIds.clear();
for (const id of ids) selectedIds.add(id);
},
clear(): void {
selectedIds.clear();
}
};

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { formatDocumentStatus } from './documentStatusLabel';
describe('formatDocumentStatus', () => {
it('maps PLACEHOLDER to correct label', () => {
expect(formatDocumentStatus('PLACEHOLDER')).toBe('Platzhalter');
});
it('maps UPLOADED to correct label', () => {
expect(formatDocumentStatus('UPLOADED')).toBe('Hochgeladen');
});
it('maps TRANSCRIBED to correct label', () => {
expect(formatDocumentStatus('TRANSCRIBED')).toBe('Transkribiert');
});
it('maps REVIEWED to correct label', () => {
expect(formatDocumentStatus('REVIEWED')).toBe('Geprüft');
});
it('maps ARCHIVED to correct label', () => {
expect(formatDocumentStatus('ARCHIVED')).toBe('Archiviert');
});
it('returns fallback for unknown status', () => {
expect(formatDocumentStatus('SOMETHING_NEW')).toBe('Unbekannt');
});
});

View File

@@ -0,0 +1,22 @@
import { m } from '$lib/paraglide/messages.js';
/**
* Maps a document status string to a localised human-readable label.
* Falls back to "Unknown" for unrecognised values.
*/
export function formatDocumentStatus(status: string): string {
switch (status) {
case 'PLACEHOLDER':
return m.doc_status_placeholder();
case 'UPLOADED':
return m.doc_status_uploaded();
case 'TRANSCRIBED':
return m.doc_status_transcribed();
case 'REVIEWED':
return m.doc_status_reviewed();
case 'ARCHIVED':
return m.doc_status_archived();
default:
return m.doc_status_unknown();
}
}

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
describe('parseFilename', () => {
describe('date-first patterns', () => {
it('YYYY-MM-DD_Lastname_Firstname', () => {
expect(parseFilename('1965-03-12_Mueller_Hans.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('YYYYMMDD_Lastname_Firstname', () => {
expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('YYYYMMDD_compound_lastname_Firstname', () => {
expect(parseFilename('18881025_de_Gruyter_Walter.pdf')).toEqual({
dateIso: '1888-10-25',
personName: 'Walter de Gruyter',
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
});
});
it('handles umlauts in names', () => {
const result = parseFilename('2024-01-15_Müller_Jürgen.pdf');
expect(result.personName).toBe('Jürgen Müller');
});
});
describe('date-last patterns', () => {
it('Lastname_Firstname_YYYY-MM-DD', () => {
expect(parseFilename('Mueller_Hans_1965-03-12.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('Lastname_Firstname_YYYYMMDD', () => {
expect(parseFilename('Mueller_Hans_19650312.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('compound_lastname_Firstname_YYYYMMDD', () => {
expect(parseFilename('de_Gruyter_Walter_18881025.pdf')).toEqual({
dateIso: '1888-10-25',
personName: 'Walter de Gruyter',
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
});
});
});
describe('non-matching filenames', () => {
it('returns empty for date-only filename', () => {
expect(parseFilename('1965-03-12.pdf')).toEqual({});
});
it('returns empty for two segments with no date', () => {
expect(parseFilename('Mueller_Hans.pdf')).toEqual({});
});
it('returns empty for unstructured filename', () => {
expect(parseFilename('scan_001.pdf')).toEqual({});
});
it('returns empty for three name segments with no date', () => {
expect(parseFilename('Mueller_Hans_Juergen.pdf')).toEqual({});
});
it('returns empty for filename without extension', () => {
expect(parseFilename('1965-03-12_Mueller_Hans')).toEqual({});
});
it('rejects implausible date (month 13)', () => {
expect(parseFilename('19651345_Mueller_Hans.pdf')).toEqual({});
});
});
});
describe('bulkTitleFromFilename', () => {
it('replaces underscores with spaces', () => {
expect(bulkTitleFromFilename('hello_world.pdf')).toBe('hello world');
});
it('replaces hyphens with spaces', () => {
expect(bulkTitleFromFilename('2024-01-01_Max.pdf')).toBe('2024 01 01 Max');
});
it('collapses multiple separators', () => {
expect(bulkTitleFromFilename('foo__bar--baz.pdf')).toBe('foo bar baz');
});
it('strips extension', () => {
expect(bulkTitleFromFilename('document.pdf')).toBe('document');
});
});
describe('stripExtension', () => {
it('removes the extension', () => {
expect(stripExtension('document.pdf')).toBe('document');
});
it('removes only the last extension', () => {
expect(stripExtension('archive.tar.gz')).toBe('archive.tar');
});
it('leaves names without extension unchanged', () => {
expect(stripExtension('nodotfile')).toBe('nodotfile');
});
});

View File

@@ -0,0 +1,87 @@
import { isoToGerman } from '$lib/utils/date';
export interface FilenameParseResult {
/** ISO format: YYYY-MM-DD */
dateIso?: string;
/** "Firstname Lastname" — order reversed from filename convention */
personName?: string;
/** Ready-to-use title, e.g. "Hans Mueller (12.03.1965)" */
suggestedTitle?: string;
}
// A date token is either YYYY-MM-DD or YYYYMMDD with a plausible month/day range.
function tryParseDate(s: string): string | undefined {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const m = parseInt(s.slice(5, 7));
const d = parseInt(s.slice(8, 10));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
} else if (/^\d{8}$/.test(s)) {
const m = parseInt(s.slice(4, 6));
const d = parseInt(s.slice(6, 8));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}
return undefined;
}
const NAME_PART = /^\p{L}+$/u;
/**
* Parses a structured filename and extracts a date and person name.
*
* Supported conventions (date-first or date-last, compound last names supported):
* YYYY-MM-DD_Lastname_Firstname.ext
* YYYYMMDD_Lastname_Firstname.ext
* YYYYMMDD_de_Gruyter_Walter.ext ← compound last name: lastName="de Gruyter"
* Lastname_Firstname_YYYY-MM-DD.ext
* Lastname_Firstname_YYYYMMDD.ext
* de_Gruyter_Walter_YYYYMMDD.ext ← compound last name: lastName="de Gruyter"
*
* Algorithm: split on "_", identify the date token (first or last segment),
* treat the outermost remaining segment as firstName, rest as lastName parts.
* Returns {} for anything that doesn't match cleanly.
*/
export function parseFilename(filename: string): FilenameParseResult {
const dot = filename.lastIndexOf('.');
if (dot < 0) return {}; // no extension — not a real file
const stem = filename.slice(0, dot);
const parts = stem.split('_');
// Minimum: date + at least one lastName segment + firstName = 3 parts
if (parts.length < 3) return {};
let dateIso: string;
let nameParts: string[];
const dateFromFirst = tryParseDate(parts[0]);
if (dateFromFirst) {
dateIso = dateFromFirst;
nameParts = parts.slice(1);
} else {
const dateFromLast = tryParseDate(parts[parts.length - 1]);
if (!dateFromLast) return {};
dateIso = dateFromLast;
nameParts = parts.slice(0, -1);
}
// Need at least lastName + firstName after removing the date
if (nameParts.length < 2) return {};
// All name segments must be pure letters (covers umlauts via \p{L})
if (!nameParts.every((p) => NAME_PART.test(p))) return {};
const firstName = nameParts[nameParts.length - 1];
const lastName = nameParts.slice(0, -1).join(' ');
const personName = `${firstName} ${lastName}`;
const suggestedTitle = `${personName} (${isoToGerman(dateIso)})`;
return { dateIso, personName, suggestedTitle };
}
export function stripExtension(filename: string): string {
return filename.replace(/\.[^/.]+$/, '');
}
export function bulkTitleFromFilename(filename: string): string {
return stripExtension(filename).replace(/[_-]+/g, ' ').trim();
}

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { groupDocuments } from './groupDocuments';
type Doc = {
id: string;
documentDate?: string | null;
sender?: { displayName: string } | null;
receivers?: { displayName: string }[];
};
const doc = (overrides: Partial<Doc> & { id: string }): Doc => ({
documentDate: null,
sender: null,
receivers: [],
...overrides
});
// ─── DATE sort ───────────────────────────────────────────────────────────────
describe('groupDocuments — DATE sort', () => {
it('produces one group per distinct year', () => {
const docs = [
doc({ id: 'a', documentDate: '1923-04-12' }),
doc({ id: 'b', documentDate: '1938-01-01' }),
doc({ id: 'c', documentDate: '1965-08-03' })
];
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
expect(groups.map((g) => g.label)).toEqual(['1923', '1938', '1965']);
expect(groups.every((g) => g.documents.length === 1)).toBe(true);
});
it('puts multiple docs from the same year into one group', () => {
const docs = [
doc({ id: 'a', documentDate: '1938-03-01' }),
doc({ id: 'b', documentDate: '1938-11-15' })
];
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe('1938');
expect(groups[0].documents).toHaveLength(2);
});
it('places undated docs in the fallback group at the bottom', () => {
const docs = [
doc({ id: 'a', documentDate: '1938-01-01' }),
doc({ id: 'b', documentDate: null }),
doc({ id: 'c', documentDate: null })
];
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe('1938');
expect(groups[1].label).toBe('Undatiert');
expect(groups[1].documents.map((d) => d.id)).toEqual(['b', 'c']);
});
it('returns one group with fallback label when all docs are undated', () => {
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe('Undatiert');
});
it('returns one group when all docs are from the same year', () => {
const docs = [
doc({ id: 'a', documentDate: '1938-01-01' }),
doc({ id: 'b', documentDate: '1938-06-15' })
];
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
expect(groups).toHaveLength(1);
});
});
// ─── SENDER sort ─────────────────────────────────────────────────────────────
describe('groupDocuments — SENDER sort', () => {
it('produces one group per distinct sender', () => {
const docs = [
doc({ id: 'a', sender: { displayName: 'Anna Müller' } }),
doc({ id: 'b', sender: { displayName: 'Karl Bauer' } }),
doc({ id: 'c', sender: { displayName: 'Anna Müller' } })
];
const groups = groupDocuments(docs, 'SENDER', 'Unbekannt');
expect(groups.map((g) => g.label)).toEqual(['Anna Müller', 'Karl Bauer']);
expect(groups[0].documents).toHaveLength(2);
expect(groups[1].documents).toHaveLength(1);
});
it('places docs with no sender in the fallback group at the bottom', () => {
const docs = [
doc({ id: 'a', sender: { displayName: 'Anna Müller' } }),
doc({ id: 'b', sender: null })
];
const groups = groupDocuments(docs, 'SENDER', 'Unbekannt');
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe('Anna Müller');
expect(groups[1].label).toBe('Unbekannt');
expect(groups[1].documents[0].id).toBe('b');
});
});
// ─── RECEIVER sort ───────────────────────────────────────────────────────────
describe('groupDocuments — RECEIVER sort', () => {
it('a doc with two receivers appears in both receiver groups', () => {
const docs = [
doc({
id: 'a',
receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }]
})
];
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
expect(groups.map((g) => g.label)).toEqual(['Anna', 'Karl']);
expect(groups[0].documents[0].id).toBe('a');
expect(groups[1].documents[0].id).toBe('a');
});
it('places docs with no receivers in the fallback group at the bottom', () => {
const docs = [
doc({ id: 'a', receivers: [{ displayName: 'Anna' }] }),
doc({ id: 'b', receivers: [] })
];
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe('Anna');
expect(groups[1].label).toBe('Unbekannt');
expect(groups[1].documents[0].id).toBe('b');
});
it('composite keys are unique: groupLabel + doc.id identifies each item', () => {
const docs = [
doc({ id: 'a', receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }] }),
doc({ id: 'b', receivers: [{ displayName: 'Anna' }] })
];
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
const keys = groups.flatMap((g) => g.documents.map((d) => `${g.label}-${d.id}`));
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
});
// ─── Non-groupable sorts ──────────────────────────────────────────────────────
describe('groupDocuments — non-groupable sorts', () => {
it('TITLE sort returns one group containing all documents', () => {
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
const groups = groupDocuments(docs, 'TITLE', 'Undatiert');
expect(groups).toHaveLength(1);
expect(groups[0].documents).toHaveLength(2);
});
it('UPLOAD_DATE sort returns one group containing all documents', () => {
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
const groups = groupDocuments(docs, 'UPLOAD_DATE', 'Undatiert');
expect(groups).toHaveLength(1);
expect(groups[0].documents).toHaveLength(2);
});
});
// ─── Edge cases ──────────────────────────────────────────────────────────────
describe('groupDocuments — edge cases', () => {
it('returns empty array for an empty document list', () => {
expect(groupDocuments([], 'DATE', 'Undatiert')).toEqual([]);
});
});

View File

@@ -0,0 +1,56 @@
export type GroupableDoc = {
id: string;
documentDate?: string | null;
sender?: { displayName: string } | null;
receivers?: { displayName: string }[];
};
export type DocumentGroup<T extends GroupableDoc> = {
label: string;
documents: T[];
};
const GROUPABLE_SORTS = ['DATE', 'SENDER', 'RECEIVER'] as const;
type GroupableSort = (typeof GROUPABLE_SORTS)[number];
export function groupDocuments<T extends GroupableDoc>(
docs: T[],
sort: string,
fallbackLabel: string
): DocumentGroup<T>[] {
if (docs.length === 0) return [];
if (!GROUPABLE_SORTS.includes(sort as GroupableSort)) {
return [{ label: '', documents: [...docs] }];
}
const groupMap = new Map<string, T[]>();
const fallbackDocs: T[] = [];
for (const doc of docs) {
const keys = extractGroupKeys(doc, sort as GroupableSort);
if (keys.length === 0) {
fallbackDocs.push(doc);
} else {
for (const key of keys) {
if (!groupMap.has(key)) groupMap.set(key, []);
groupMap.get(key)!.push(doc);
}
}
}
const groups = [...groupMap.entries()].map(([label, documents]) => ({ label, documents }));
if (fallbackDocs.length > 0) groups.push({ label: fallbackLabel, documents: fallbackDocs });
return groups;
}
function extractGroupKeys<T extends GroupableDoc>(doc: T, sort: GroupableSort): string[] {
if (sort === 'DATE') {
const year = doc.documentDate
? String(new Date(doc.documentDate + 'T12:00:00').getFullYear())
: null;
return year ? [year] : [];
}
if (sort === 'SENDER') return doc.sender ? [doc.sender.displayName] : [];
if (sort === 'RECEIVER') return (doc.receivers ?? []).map((r) => r.displayName);
return [];
}

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { applyOffsets } from './search';
describe('applyOffsets', () => {
it('returns single plain segment when offsets is empty', () => {
expect(applyOffsets('Hallo Welt', [])).toEqual([{ text: 'Hallo Welt', highlight: false }]);
});
it('highlights a single term at the start', () => {
expect(applyOffsets('Brief an Anna', [{ start: 0, length: 5 }])).toEqual([
{ text: 'Brief', highlight: true },
{ text: ' an Anna', highlight: false }
]);
});
it('highlights a term in the middle', () => {
expect(applyOffsets('Der Brief von Anna', [{ start: 4, length: 5 }])).toEqual([
{ text: 'Der ', highlight: false },
{ text: 'Brief', highlight: true },
{ text: ' von Anna', highlight: false }
]);
});
it('highlights a term at the end', () => {
expect(applyOffsets('Brief an Anna', [{ start: 9, length: 4 }])).toEqual([
{ text: 'Brief an ', highlight: false },
{ text: 'Anna', highlight: true }
]);
});
it('handles two non-overlapping offsets in order', () => {
expect(
applyOffsets('Anna und Brief', [
{ start: 0, length: 4 },
{ start: 9, length: 5 }
])
).toEqual([
{ text: 'Anna', highlight: true },
{ text: ' und ', highlight: false },
{ text: 'Brief', highlight: true }
]);
});
it('merges overlapping offsets into the longest span', () => {
// [0,7) and [3,9) overlap → merged [0,max(7,9)) = [0,9) = "Hello wor"
expect(
applyOffsets('Hello world', [
{ start: 0, length: 7 },
{ start: 3, length: 6 }
])
).toEqual([
{ text: 'Hello wor', highlight: true },
{ text: 'ld', highlight: false }
]);
});
it('merges adjacent (touching) offsets', () => {
// [0,3) and [3,6) are adjacent → merged [0,6)
expect(
applyOffsets('Hallo Welt', [
{ start: 0, length: 3 },
{ start: 3, length: 3 }
])
).toEqual([
{ text: 'Hallo ', highlight: true },
{ text: 'Welt', highlight: false }
]);
});
it('clamps offset that extends beyond text length', () => {
expect(applyOffsets('Hi', [{ start: 0, length: 100 }])).toEqual([
{ text: 'Hi', highlight: true }
]);
});
it('ignores a completely out-of-bounds offset', () => {
expect(applyOffsets('Hi', [{ start: 10, length: 5 }])).toEqual([
{ text: 'Hi', highlight: false }
]);
});
it('sorts unsorted offsets correctly', () => {
// Offsets provided in reverse order: second term first
expect(
applyOffsets('Anna und Brief', [
{ start: 9, length: 5 },
{ start: 0, length: 4 }
])
).toEqual([
{ text: 'Anna', highlight: true },
{ text: ' und ', highlight: false },
{ text: 'Brief', highlight: true }
]);
});
it('clamps negative start to 0 and highlights from the beginning', () => {
// start = -2, length = 5 → effective range [-2, 3) → clamped to [0, 3)
expect(applyOffsets('Hello', [{ start: -2, length: 5 }])).toEqual([
{ text: 'Hel', highlight: true },
{ text: 'lo', highlight: false }
]);
});
it('ignores offset whose end is also negative', () => {
// start = -5, length = 2 → end = -3, completely before text
expect(applyOffsets('Hi', [{ start: -5, length: 2 }])).toEqual([
{ text: 'Hi', highlight: false }
]);
});
});

View File

@@ -0,0 +1,46 @@
export type TextSegment = { text: string; highlight: boolean };
export type MatchOffset = { start: number; length: number };
/**
* Converts a flat string and a list of character-level highlight offsets into
* an array of text segments that can be rendered without {@html}.
*
* Offsets are sorted and merged (overlapping spans become the longest enclosing
* span) before processing. Out-of-bounds offsets are clamped or dropped.
*
* @param text The display text (no delimiter characters).
* @param offsets Character offsets produced by the backend (Java char positions,
* compatible with JavaScript String indexing).
*/
export function applyOffsets(text: string, offsets: MatchOffset[]): TextSegment[] {
if (!offsets.length) return [{ text, highlight: false }];
// Sort by start position and merge overlapping / adjacent spans
const sorted = [...offsets].sort((a, b) => a.start - b.start);
const merged: { start: number; end: number }[] = [];
for (const { start, length } of sorted) {
const end = start + length;
if (end <= 0 || start >= text.length) continue; // completely out of bounds
const clampedStart = Math.max(0, start);
const clampedEnd = Math.min(text.length, end);
const last = merged[merged.length - 1];
if (!last || clampedStart > last.end) {
merged.push({ start: clampedStart, end: clampedEnd });
} else {
last.end = Math.max(last.end, clampedEnd);
}
}
if (!merged.length) return [{ text, highlight: false }];
const segments: TextSegment[] = [];
let pos = 0;
for (const { start, end } of merged) {
if (pos < start) segments.push({ text: text.slice(pos, start), highlight: false });
segments.push({ text: text.slice(start, end), highlight: true });
pos = end;
}
if (pos < text.length) segments.push({ text: text.slice(pos), highlight: false });
return segments;
}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { initialTranscription = '' }: { initialTranscription?: string } = $props();
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_transcription()}
</h2>
<textarea
id="transcription"
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>{initialTranscription}</textarea
>
</div>

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { validateFile, MAX_SIZE_BYTES } from './validateFile';
function makeFile(type: string, size: number): File {
return new File(['x'.repeat(Math.min(size, 100))], 'test.file', { type });
}
describe('validateFile', () => {
it('returns null for a valid PDF under 50 MB', () => {
const file = makeFile('application/pdf', 1024);
expect(validateFile(file)).toBeNull();
});
it('returns null for a valid JPEG', () => {
expect(validateFile(makeFile('image/jpeg', 1024))).toBeNull();
});
it('returns null for a valid PNG', () => {
expect(validateFile(makeFile('image/png', 1024))).toBeNull();
});
it('returns null for a valid TIFF', () => {
expect(validateFile(makeFile('image/tiff', 1024))).toBeNull();
});
it('returns "type" for an unsupported MIME type', () => {
const file = makeFile('text/plain', 100);
expect(validateFile(file)).toBe('type');
});
it('returns "size" for a file exceeding 50 MB', () => {
const oversized = new File(['x'], 'big.pdf', { type: 'application/pdf' });
Object.defineProperty(oversized, 'size', { value: MAX_SIZE_BYTES + 1 });
expect(validateFile(oversized)).toBe('size');
});
});

View File

@@ -0,0 +1,10 @@
export const ALLOWED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']);
export const MAX_SIZE_BYTES = 50 * 1024 * 1024;
export type FileValidationError = 'type' | 'size';
export function validateFile(file: File): FileValidationError | null {
if (!ALLOWED_TYPES.has(file.type)) return 'type';
if (file.size > MAX_SIZE_BYTES) return 'size';
return null;
}