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:
535
frontend/src/lib/document/BulkDocumentEditLayout.svelte
Normal file
535
frontend/src/lib/document/BulkDocumentEditLayout.svelte
Normal 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>
|
||||
543
frontend/src/lib/document/BulkDocumentEditLayout.svelte.spec.ts
Normal file
543
frontend/src/lib/document/BulkDocumentEditLayout.svelte.spec.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/document/BulkDropZone.svelte
Normal file
80
frontend/src/lib/document/BulkDropZone.svelte
Normal 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>
|
||||
39
frontend/src/lib/document/BulkDropZone.svelte.spec.ts
Normal file
39
frontend/src/lib/document/BulkDropZone.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/document/BulkSelectionBar.svelte
Normal file
74
frontend/src/lib/document/BulkSelectionBar.svelte
Normal 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}
|
||||
122
frontend/src/lib/document/BulkSelectionBar.svelte.spec.ts
Normal file
122
frontend/src/lib/document/BulkSelectionBar.svelte.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
73
frontend/src/lib/document/DashboardNeedsMetadata.svelte
Normal file
73
frontend/src/lib/document/DashboardNeedsMetadata.svelte
Normal 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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
148
frontend/src/lib/document/DescriptionSection.svelte
Normal file
148
frontend/src/lib/document/DescriptionSection.svelte
Normal 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>
|
||||
57
frontend/src/lib/document/DescriptionSection.svelte.spec.ts
Normal file
57
frontend/src/lib/document/DescriptionSection.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
223
frontend/src/lib/document/DocumentEditLayout.svelte
Normal file
223
frontend/src/lib/document/DocumentEditLayout.svelte
Normal 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>
|
||||
238
frontend/src/lib/document/DocumentMetadataDrawer.svelte
Normal file
238
frontend/src/lib/document/DocumentMetadataDrawer.svelte
Normal 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>
|
||||
119
frontend/src/lib/document/DocumentMetadataDrawer.svelte.spec.ts
Normal file
119
frontend/src/lib/document/DocumentMetadataDrawer.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
147
frontend/src/lib/document/DocumentMultiSelect.svelte
Normal file
147
frontend/src/lib/document/DocumentMultiSelect.svelte
Normal 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>
|
||||
126
frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts
Normal file
126
frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
229
frontend/src/lib/document/DocumentRow.svelte
Normal file
229
frontend/src/lib/document/DocumentRow.svelte
Normal 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>
|
||||
325
frontend/src/lib/document/DocumentRow.svelte.spec.ts
Normal file
325
frontend/src/lib/document/DocumentRow.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/document/DocumentStatusChip.svelte
Normal file
20
frontend/src/lib/document/DocumentStatusChip.svelte
Normal 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>
|
||||
56
frontend/src/lib/document/DocumentThumbnail.svelte
Normal file
56
frontend/src/lib/document/DocumentThumbnail.svelte
Normal 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>
|
||||
303
frontend/src/lib/document/DocumentTopBar.svelte
Normal file
303
frontend/src/lib/document/DocumentTopBar.svelte
Normal 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>
|
||||
115
frontend/src/lib/document/DocumentViewer.svelte
Normal file
115
frontend/src/lib/document/DocumentViewer.svelte
Normal 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>
|
||||
41
frontend/src/lib/document/EnrichmentBlock.svelte
Normal file
41
frontend/src/lib/document/EnrichmentBlock.svelte
Normal 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}
|
||||
89
frontend/src/lib/document/EnrichmentBlock.svelte.spec.ts
Normal file
89
frontend/src/lib/document/EnrichmentBlock.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
16
frontend/src/lib/document/FieldLabelBadge.svelte
Normal file
16
frontend/src/lib/document/FieldLabelBadge.svelte
Normal 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>
|
||||
28
frontend/src/lib/document/FieldLabelBadge.svelte.spec.ts
Normal file
28
frontend/src/lib/document/FieldLabelBadge.svelte.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
154
frontend/src/lib/document/FileSwitcherStrip.svelte
Normal file
154
frontend/src/lib/document/FileSwitcherStrip.svelte
Normal 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>
|
||||
145
frontend/src/lib/document/FileSwitcherStrip.svelte.spec.ts
Normal file
145
frontend/src/lib/document/FileSwitcherStrip.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
75
frontend/src/lib/document/ReadyColumn.svelte
Normal file
75
frontend/src/lib/document/ReadyColumn.svelte
Normal 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}
|
||||
78
frontend/src/lib/document/ReadyColumn.svelte.spec.ts
Normal file
78
frontend/src/lib/document/ReadyColumn.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/document/ScopeCard.svelte
Normal file
40
frontend/src/lib/document/ScopeCard.svelte
Normal 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>
|
||||
32
frontend/src/lib/document/ScopeCard.svelte.spec.ts
Normal file
32
frontend/src/lib/document/ScopeCard.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
frontend/src/lib/document/ScriptTypeSelect.svelte
Normal file
27
frontend/src/lib/document/ScriptTypeSelect.svelte
Normal 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>
|
||||
62
frontend/src/lib/document/SegmentationColumn.svelte
Normal file
62
frontend/src/lib/document/SegmentationColumn.svelte
Normal 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}
|
||||
67
frontend/src/lib/document/SegmentationColumn.svelte.spec.ts
Normal file
67
frontend/src/lib/document/SegmentationColumn.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
98
frontend/src/lib/document/ThumbnailRow.svelte
Normal file
98
frontend/src/lib/document/ThumbnailRow.svelte
Normal 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">
|
||||
“{doc.summary}”
|
||||
</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>
|
||||
228
frontend/src/lib/document/ThumbnailRow.svelte.spec.ts
Normal file
228
frontend/src/lib/document/ThumbnailRow.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/document/UploadSaveBar.svelte
Normal file
69
frontend/src/lib/document/UploadSaveBar.svelte
Normal 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>
|
||||
48
frontend/src/lib/document/UploadSaveBar.svelte.spec.ts
Normal file
48
frontend/src/lib/document/UploadSaveBar.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/document/UploadSuccessBanner.svelte
Normal file
62
frontend/src/lib/document/UploadSuccessBanner.svelte
Normal 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>
|
||||
57
frontend/src/lib/document/UploadSuccessBanner.svelte.spec.ts
Normal file
57
frontend/src/lib/document/UploadSuccessBanner.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
115
frontend/src/lib/document/UploadZone.svelte
Normal file
115
frontend/src/lib/document/UploadZone.svelte
Normal 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>
|
||||
25
frontend/src/lib/document/UploadZone.svelte.spec.ts
Normal file
25
frontend/src/lib/document/UploadZone.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
frontend/src/lib/document/UploadZone.svelte.test.ts
Normal file
115
frontend/src/lib/document/UploadZone.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
142
frontend/src/lib/document/WhoWhenSection.svelte
Normal file
142
frontend/src/lib/document/WhoWhenSection.svelte
Normal 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>
|
||||
42
frontend/src/lib/document/WhoWhenSection.svelte.spec.ts
Normal file
42
frontend/src/lib/document/WhoWhenSection.svelte.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
3
frontend/src/lib/document/__mocks__/navigatingStore.ts
Normal file
3
frontend/src/lib/document/__mocks__/navigatingStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const navigatingStore = writable<unknown | null>(null);
|
||||
76
frontend/src/lib/document/bulkSelection.svelte.spec.ts
Normal file
76
frontend/src/lib/document/bulkSelection.svelte.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
36
frontend/src/lib/document/bulkSelection.svelte.ts
Normal file
36
frontend/src/lib/document/bulkSelection.svelte.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
28
frontend/src/lib/document/documentStatusLabel.spec.ts
Normal file
28
frontend/src/lib/document/documentStatusLabel.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
frontend/src/lib/document/documentStatusLabel.ts
Normal file
22
frontend/src/lib/document/documentStatusLabel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
119
frontend/src/lib/document/filename.spec.ts
Normal file
119
frontend/src/lib/document/filename.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
87
frontend/src/lib/document/filename.ts
Normal file
87
frontend/src/lib/document/filename.ts
Normal 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();
|
||||
}
|
||||
165
frontend/src/lib/document/groupDocuments.spec.ts
Normal file
165
frontend/src/lib/document/groupDocuments.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/document/groupDocuments.ts
Normal file
56
frontend/src/lib/document/groupDocuments.ts
Normal 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 [];
|
||||
}
|
||||
110
frontend/src/lib/document/search.spec.ts
Normal file
110
frontend/src/lib/document/search.spec.ts
Normal 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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/document/search.ts
Normal file
46
frontend/src/lib/document/search.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
36
frontend/src/lib/document/validateFile.spec.ts
Normal file
36
frontend/src/lib/document/validateFile.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
10
frontend/src/lib/document/validateFile.ts
Normal file
10
frontend/src/lib/document/validateFile.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user