feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
This commit was merged in pull request #787.
This commit is contained in:
@@ -1,21 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
|
||||
/**
|
||||
* Exactly the fields this picker reads — id for selection/dedup, the rest for
|
||||
* the honest date label. A full `Document` and a `DocumentListItem` are both
|
||||
* structurally assignable, so the search results need no cast.
|
||||
*/
|
||||
type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
import {
|
||||
createDocumentTypeahead,
|
||||
formatDocumentOption,
|
||||
type DocumentOption
|
||||
} from './documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
selectedDocuments?: DocumentOption[];
|
||||
@@ -30,13 +20,16 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: DocumentOption[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
const picker = createDocumentTypeahead();
|
||||
|
||||
// Filter out already-selected documents from typeahead results.
|
||||
const filteredResults = $derived(
|
||||
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
|
||||
);
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
|
||||
}
|
||||
|
||||
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: DocumentListItem[] } = await res.json();
|
||||
const docs: DocumentOption[] = body.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
documentDate: it.documentDate,
|
||||
metaDatePrecision: it.metaDatePrecision,
|
||||
metaDateEnd: it.metaDateEnd
|
||||
}));
|
||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||
}
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
if (searchTerm.trim().length >= 1) {
|
||||
picker.setQuery(searchTerm);
|
||||
} else {
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
|
||||
function selectDocument(doc: DocumentOption) {
|
||||
selectedDocuments = [...selectedDocuments, doc];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
picker.close();
|
||||
}
|
||||
|
||||
function removeDocument(id: string | undefined) {
|
||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
function formatDocLabel(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
doc.metaDateEnd,
|
||||
null,
|
||||
getLocale()
|
||||
);
|
||||
return `${doc.title} · ${label}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
|
||||
<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"
|
||||
>
|
||||
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
{formatDocLabel(doc)}
|
||||
{formatDocumentOption(doc)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeDocument(doc.id)}
|
||||
@@ -136,24 +94,23 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => {
|
||||
updateDropdownPosition();
|
||||
showDropdown = true;
|
||||
}}
|
||||
onfocus={() => updateDropdownPosition()}
|
||||
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)}
|
||||
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
|
||||
<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}
|
||||
{#if picker.loading}
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||
{:else if picker.error}
|
||||
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
|
||||
{:else}
|
||||
{#each results as doc (doc.id)}
|
||||
{#each filteredResults as doc (doc.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||
onclick={() => selectDocument(doc)}
|
||||
@@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{formatDocLabel(doc)}
|
||||
{formatDocumentOption(doc)}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user