Files
familienarchiv/frontend/src/routes/documents/+page.svelte
Marcel 52827ccc87 feat(documents): hide timeline density widget below lg (#385)
Tablet (640–1024px) is exactly the iPad audience for transcribers.
At 240 monthly bars on an 800px column the bars fall to ~3.3px wide,
well below the 44×44 touch-target floor. Bumps the visibility class
from hidden sm:block to hidden lg:block and matches the page.ts
matchMedia gate to (min-width: 1024px). Closes Leonie's tablet
touch-target finding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:00:47 +02:00

334 lines
10 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 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'));
function hasAdvancedFilters() {
return (
(data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to
);
}
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';
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.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. `page` is intentionally
* not carried over — any filter change implicitly resets back to page 0.
*/
function triggerSearch(zoomOverride?: { 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,
zoomFrom: zoomOverride ? zoomOverride.zoomFrom : data.zoomFrom,
zoomTo: zoomOverride ? zoomOverride.zoomTo : data.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'
},
targetPage
);
const qs = params.toString();
return qs ? `/documents?${qs}` : '/documents';
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => triggerSearch(), 500);
}
function handleImmediateSearch() {
clearTimeout(searchTimer);
triggerSearch();
}
// 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;
triggerSearch();
}
});
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'
});
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.
$effect(() => {
if (!qFocused) q = data.q || '';
from = data.from || '';
to = data.to || '';
senderId = data.senderId || '';
receiverId = data.receiverId || '';
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';
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}
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) {
triggerSearch({
zoomFrom: event.zoomFrom ?? null,
zoomTo: event.zoomTo ?? null
});
} else {
triggerSearch();
}
}}
onzoomchange={(event) => {
triggerSearch({
zoomFrom: event?.zoomFrom ?? null,
zoomTo: 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}
/>
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
</main>
<BulkSelectionBar canWrite={data.canWrite} />