562 lines
18 KiB
Svelte
562 lines
18 KiB
Svelte
<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} />
|