Files
familienarchiv/frontend/src/routes/documents/+page.svelte
Marcel c6137a26a2
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Failing after 4m3s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
feat(documents): show global undated count chip on the filter toggle
Surface the backend's global undatedCount on the "Nur undatierte" toggle as
a count chip — the total undated documents matching the current filter
across all pages, not the page slice. The loader forwards undatedCount
straight through (defaulting to 0); the chip hides at 0 and stays visible
regardless of the toggle state so it advertises the triage backlog size.

generate:api was hand-edited (undatedCount added to DocumentSearchResult) —
CI must re-run npm run generate:api to confirm parity.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:42:57 +02:00

368 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { navigating } from '$app/state';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from '../SearchFilterBar.svelte';
import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/shared/primitives/Pagination.svelte';
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
// Local state initialised from server-returned filter values.
// untrack() prevents infinite reactive loops during initialisation.
let q = $state(untrack(() => data.q || ''));
let qFocused = $state(false);
let from = $state(untrack(() => data.from || ''));
let to = $state(untrack(() => data.to || ''));
let senderId = $state(untrack(() => data.senderId || ''));
let receiverId = $state(untrack(() => data.receiverId || ''));
let initialSenderName = $state(untrack(() => data.initialSenderName ?? ''));
let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? ''));
let navKey = $state(0);
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
untrack(() => (data.tags || []).map((name: string) => ({ name })))
);
let sort = $state(untrack(() => data.sort || 'DATE'));
let dir = $state(untrack(() => data.dir || 'desc'));
let tagQ = $state(untrack(() => data.tagQ || ''));
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
let undated = $state(untrack(() => data.undated ?? false));
function hasAdvancedFilters() {
return (
(data.tags?.length ?? 0) > 0 ||
!!data.senderId ||
!!data.receiverId ||
!!data.from ||
!!data.to ||
!!data.undated
);
}
let showAdvanced = $state(untrack(hasAdvancedFilters));
let searchTimer: ReturnType<typeof setTimeout>;
type FilterSnapshot = {
q: string;
from: string;
to: string;
senderId: string;
receiverId: string;
tags: string[];
sort: string;
dir: string;
tagQ: string;
tagOp: 'AND' | 'OR';
undated: boolean;
zoomFrom?: string | null;
zoomTo?: string | null;
};
/**
* Builds a URLSearchParams from a filter snapshot. Single source of truth for
* which params the `/documents` URL understands — add a filter here and both
* filter-change nav (triggerSearch) and page nav (buildPageHref) will pick it
* up. `page` is appended only when > 0 so the default page 0 stays out of the
* URL, keeping the filter-change-resets-to-page-0 behaviour implicit.
*/
function buildSearchParams(filters: FilterSnapshot, targetPage?: number): SvelteURLSearchParams {
const params = new SvelteURLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.from) params.set('from', filters.from);
if (filters.to) params.set('to', filters.to);
if (filters.senderId) params.set('senderId', filters.senderId);
if (filters.receiverId) params.set('receiverId', filters.receiverId);
filters.tags.forEach((tag) => params.append('tag', tag));
if (filters.sort) params.set('sort', filters.sort);
if (filters.dir) params.set('dir', filters.dir);
if (filters.tagQ) params.set('tagQ', filters.tagQ);
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
if (filters.undated) params.set('undated', 'true');
if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom);
if (filters.zoomTo) params.set('zoomTo', filters.zoomTo);
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
return params;
}
/**
* Rebuilds the URL from the CURRENT local filter state, preserving the zoom
* range carried in `data.zoom{From,To}`. `page` is intentionally not carried
* over — any filter change implicitly resets back to page 0.
*/
function triggerSearchKeepZoom() {
navigateWithZoom(data.zoomFrom ?? null, data.zoomTo ?? null);
}
/**
* Rebuilds the URL from the CURRENT local filter state and replaces the zoom
* range with the provided values (or clears it if both are null).
*/
function triggerSearchWithZoom(zoomFrom: string | null, zoomTo: string | null) {
navigateWithZoom(zoomFrom, zoomTo);
}
function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) {
const params = buildSearchParams({
q,
from,
to,
senderId,
receiverId,
tags: tagNames.map((t) => t.name),
sort,
dir,
tagQ,
tagOp: tagOperator,
undated,
zoomFrom,
zoomTo
});
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
}
/**
* Builds the href for a Pagination prev/next link. Preserves every filter
* param from server `data` and updates `page`. Uses a normal <a href> (not
* goto) so SvelteKit's default scroll restoration brings the user to the top
* of the new slice — the expected behaviour for page navigation.
*/
function buildPageHref(targetPage: number): string {
const params = buildSearchParams(
{
q: data.q || '',
from: data.from || '',
to: data.to || '',
senderId: data.senderId || '',
receiverId: data.receiverId || '',
tags: data.tags || [],
sort: data.sort || '',
dir: data.dir || '',
tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
undated: data.undated ?? false
},
targetPage
);
const qs = params.toString();
return qs ? `/documents?${qs}` : '/documents';
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => triggerSearchKeepZoom(), 500);
}
function handleImmediateSearch() {
clearTimeout(searchTimer);
triggerSearchKeepZoom();
}
// Trigger search reactively when the tag list changes.
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
$effect(() => {
const cur = tagNames.map((t) => t.name).join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearchKeepZoom();
}
});
let editingAll = $state(false);
let editAllError = $state<string | null>(null);
/**
* Fast path: replace the current selection with every document matching the
* active filter (across all pages) and jump to the bulk-edit screen. The
* /api/documents/ids endpoint is hard-capped (5000 results); on cap overflow
* the backend returns BULK_EDIT_TOO_MANY_IDS, which we surface inline.
*/
async function editAllMatching() {
if (editingAll) return;
editingAll = true;
editAllError = null;
try {
const params = buildSearchParams({
q: data.q || '',
from: data.from || '',
to: data.to || '',
senderId: data.senderId || '',
receiverId: data.receiverId || '',
tags: data.tags || [],
sort: '',
dir: '',
tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
undated: data.undated ?? false
});
params.delete('sort');
params.delete('dir');
const res = await fetch(`/api/documents/ids?${params.toString()}`);
if (!res.ok) {
const backend = await parseBackendError(res);
editAllError = getErrorMessage(backend?.code);
return;
}
const ids: string[] = await res.json();
bulkSelectionStore.setAll(ids);
await goto('/documents/bulk-edit');
} catch {
editAllError = m.bulk_edit_all_x_failed();
} finally {
editingAll = false;
}
}
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
// navKey increments on every navigation so PersonTypeahead clears manually-typed
// terms even when initialSenderName/initialReceiverName stays '' across navigations.
$effect(() => {
if (!qFocused) q = data.q || '';
from = data.from || '';
to = data.to || '';
senderId = data.senderId || '';
receiverId = data.receiverId || '';
initialSenderName = data.initialSenderName ?? '';
initialReceiverName = data.initialReceiverName ?? '';
untrack(() => navKey++);
tagNames = (data.tags || []).map((name: string) => ({ name }));
sort = data.sort || 'DATE';
dir = data.dir || 'desc';
tagQ = data.tagQ || '';
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
undated = data.undated ?? false;
if (hasAdvancedFilters()) showAdvanced = true;
});
</script>
<svelte:head>
<title>{m.nav_documents()} Familienarchiv</title>
</svelte:head>
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row or the pagination
controls (WCAG 1.4.10 / 2.4.7). -->
<main
class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8"
class:pb-32={bulkSelectionStore.size > 0 && data.canWrite}
>
<h1 class="sr-only">{m.nav_documents()}</h1>
<SearchFilterBar
bind:q={q}
bind:from={from}
bind:to={to}
bind:senderId={senderId}
bind:receiverId={receiverId}
bind:tagNames={tagNames}
bind:showAdvanced={showAdvanced}
bind:sort={sort}
bind:dir={dir}
bind:tagQ={tagQ}
bind:tagOperator={tagOperator}
bind:undated={undated}
undatedCount={data.undatedCount ?? 0}
initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName}
navKey={navKey}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
/>
<div class="mt-3 mb-4 hidden lg:block">
<TimelineDensityFilter
density={data.density}
minDate={data.minDate}
maxDate={data.maxDate}
zoomFrom={data.zoomFrom}
zoomTo={data.zoomTo}
from={from}
to={to}
onchange={(event) => {
from = event.from;
to = event.to;
// Drag commits filter + zoom atomically (Graylog-style range selector).
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
if ('zoomFrom' in event) {
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
} else {
triggerSearchKeepZoom();
}
}}
onzoomchange={(event) => {
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
}}
/>
</div>
<div class="mb-3 flex items-center justify-between gap-4">
<p class="font-sans text-base text-ink-2">
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
</p>
{#if data.canWrite}
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-4">
{#if data.totalElements > 0}
<button
type="button"
onclick={editAllMatching}
disabled={editingAll}
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
data-testid="bulk-edit-all-x"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{/if}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
</div>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if}
</div>
{/if}
</div>
<DocumentList
items={data.items}
q={data.q}
canWrite={data.canWrite}
error={data.error}
sort={sort}
from={data.from}
to={data.to}
/>
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
</main>
<BulkSelectionBar canWrite={data.canWrite} />