Files
familienarchiv/frontend/src/routes/documents/+page.svelte
2026-06-06 23:40:33 +02:00

562 lines
18 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 SmartSearchStatus from '../search/SmartSearchStatus.svelte';
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
import type { ChipType } from '../search/chip-types.js';
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
type NlSearchResponse = components['schemas']['NlSearchResponse'];
type DocumentSearchResult = components['schemas']['DocumentSearchResult'];
type PersonHint = components['schemas']['PersonHint'];
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
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));
// Smart (NL) search — UI-local state, resets on real page navigation (away + back).
let smartMode = $state(false);
let nlSubmitted = $state(false);
let nlLoading = $state(false);
let nlError = $state<SmartSearchErrorCode | null>(null);
let nlInterpretation = $state<NlQueryInterpretation | null>(null);
let nlResult = $state<DocumentSearchResult | null>(null);
const showNlView = $derived(smartMode && nlSubmitted);
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0);
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();
}
function resetNlState() {
nlSubmitted = false;
nlLoading = false;
nlError = null;
nlInterpretation = null;
nlResult = null;
}
/** Toggling the mode (either direction) always clears any prior NL interpretation. */
function onModeToggle() {
resetNlState();
}
/** Submit the natural-language query to the server-side parser. */
async function runSmartSearch() {
const query = q.trim();
if (query.length < 3) return;
nlSubmitted = true;
nlLoading = true;
nlError = null;
nlInterpretation = null;
nlResult = null;
try {
const res = await csrfFetch('/api/search/nl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (!res.ok) {
const backend = await parseBackendError(res);
nlError =
backend?.code === 'SMART_SEARCH_RATE_LIMITED'
? 'SMART_SEARCH_RATE_LIMITED'
: 'SMART_SEARCH_UNAVAILABLE';
return;
}
const body: NlSearchResponse = await res.json();
nlInterpretation = body.interpretation;
nlResult = body.result;
} catch {
nlError = 'SMART_SEARCH_UNAVAILABLE';
} finally {
nlLoading = false;
}
}
/** Option A empty/error fallback: drop NL mode, keep the raw query, run a keyword search. */
function switchToKeywordMode() {
resetNlState();
smartMode = false;
handleImmediateSearch();
}
/** Applies a resolved param set to the keyword filters and re-runs via GET. */
function applyResolvedAndSearch(p: {
senderId: string;
receiverId: string;
from: string;
to: string;
q: string;
}) {
resetNlState();
smartMode = false;
senderId = p.senderId;
receiverId = p.receiverId;
from = p.from;
to = p.to;
q = p.q;
handleImmediateSearch();
}
function paramsFromInterpretation(interp: NlQueryInterpretation) {
const resolved = interp.resolvedPersons;
return {
senderId: resolved.length >= 1 ? resolved[0].id : '',
receiverId: resolved.length >= 2 ? resolved[1].id : '',
from: interp.dateFrom ?? '',
to: interp.dateTo ?? '',
q: interp.keywordsApplied ? interp.keywords.join(' ') : ''
};
}
function removeChip(type: ChipType, value?: string) {
if (!nlInterpretation) return;
const p = paramsFromInterpretation(nlInterpretation);
if (type === 'sender') {
p.senderId = '';
} else if (type === 'directional') {
p.senderId = '';
p.receiverId = '';
} else if (type === 'date') {
p.from = '';
p.to = '';
} else if (type === 'keyword' && value) {
const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value);
p.q = remaining.join(' ');
} else if (type === 'theme' && value) {
const url = buildThemeRemovalUrl(nlInterpretation, value);
resetNlState();
goto(url, { keepFocus: true, noScroll: true });
return;
}
applyResolvedAndSearch(p);
}
/** Single-select disambiguation: resolved person becomes sender, chosen becomes receiver. */
function selectDisambiguated(person: PersonHint) {
if (!nlInterpretation) return;
const resolved = nlInterpretation.resolvedPersons;
applyResolvedAndSearch({
senderId: resolved.length >= 1 ? resolved[0].id : person.id,
receiverId: resolved.length >= 1 ? person.id : '',
from: nlInterpretation.dateFrom ?? '',
to: nlInterpretation.dateTo ?? '',
q: nlInterpretation.keywordsApplied ? nlInterpretation.keywords.join(' ') : ''
});
}
// 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}
bind:smartMode={smartMode}
undatedCount={data.undatedCount ?? 0}
initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName}
navKey={navKey}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}
onSmartSearch={runSmartSearch}
onModeToggle={onModeToggle}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
/>
{#if showNlView}
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
<div data-testid="smart-search-results">
{#if nlLoading}
<SmartSearchStatus status="loading" />
{:else if nlError}
<SmartSearchStatus
status="error"
errorCode={nlError}
onSwitchToKeyword={switchToKeywordMode}
/>
{:else if nlInterpretation}
{#key nlInterpretation}
<div class="mb-4">
{#if nlIsAmbiguous}
<DisambiguationPicker
persons={nlInterpretation.ambiguousPersons}
onSelect={selectDisambiguated}
/>
{:else}
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
{/if}
</div>
{#if !nlIsAmbiguous}
{#if nlHasResults}
<p class="mb-3 font-sans text-base text-ink-2">
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
</p>
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
{:else}
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
<button
type="button"
onclick={switchToKeywordMode}
class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.search_empty_retry_keyword()}
</button>
</div>
{/if}
{/if}
{/key}
{/if}
</div>
{:else}
<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} />
{/if}
</main>
<BulkSelectionBar canWrite={data.canWrite} />