Files
familienarchiv/frontend/src/routes/+page.svelte
Marcel bbfef9a22d
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m25s
CI / Backend Unit Tests (push) Successful in 2m26s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Failing after 1m49s
CI / Backend Unit Tests (pull_request) Successful in 2m2s
CI / E2E Tests (pull_request) Failing after 30m19s
feat(upload): add drag-and-drop bulk upload zone to home page
Adds a compact, unobtrusive drop zone between the search card and the
document list. Only visible to users with WRITE_ALL permission.

- Drag-and-drop or click-to-select multiple files at once
- Client-side MIME type validation with per-file error messages
- POSTs to /api/documents/quick-upload; refreshes list via invalidateAll()
- Inline feedback: success count + per-file errors
- i18n keys added to de/en/es message files

Closes #66 (frontend part)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:00:19 +01:00

491 lines
15 KiB
Svelte

<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto, invalidateAll } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let { data } = $props();
let q = $state(untrack(() => data.filters?.q || ''));
let qFocused = $state(false);
let from = $state(untrack(() => data.filters?.from || ''));
let to = $state(untrack(() => data.filters?.to || ''));
let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
let isDragging = $state(false);
let isUploading = $state(false);
let uploadMessages = $state<{ text: string; isError: boolean }[]>([]);
let fileInput: HTMLInputElement;
let searchTimer: ReturnType<typeof setTimeout>;
const hasAdvancedFilters = (filters: typeof data.filters) =>
(filters?.tags?.length ?? 0) > 0 ||
!!filters?.senderId ||
!!filters?.receiverId ||
!!filters?.from ||
!!filters?.to;
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const files = Array.from(e.dataTransfer?.files ?? []);
await uploadFiles(files);
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
await uploadFiles(files);
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return;
const messages: { text: string; isError: boolean }[] = [];
// Client-side type validation
const valid: File[] = [];
for (const file of files) {
if (!ACCEPTED_TYPES.includes(file.type)) {
messages.push({ text: m.upload_invalid_type({ filename: file.name }), isError: true });
} else {
valid.push(file);
}
}
if (valid.length === 0) {
uploadMessages = messages;
return;
}
isUploading = true;
try {
const formData = new FormData();
for (const file of valid) {
formData.append('files', file);
}
const res = await fetch('/api/documents/quick-upload', {
method: 'POST',
body: formData
});
if (res.ok) {
const result = await res.json();
if (result.created?.length > 0) {
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
}
for (const err of result.errors ?? []) {
messages.push({ text: err, isError: true });
}
await invalidateAll();
} else {
for (const file of valid) {
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
}
}
} finally {
isUploading = false;
uploadMessages = messages;
}
}
function triggerSearch() {
const params = new SvelteURLSearchParams();
if (q) params.set('q', q);
if (from) params.set('from', from);
if (to) params.set('to', to);
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
goto(`/?${params.toString()}`, {
keepFocus: true,
noScroll: true
});
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
triggerSearch();
}, 500);
}
// Trigger search when tags change
let prevTagStr = untrack(() => tagNames.join(','));
$effect(() => {
const cur = tagNames.join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch();
}
});
// Sync local state with server data after navigation.
// Guard q: skip overwrite while the user is actively typing in the search field.
$effect(() => {
if (!qFocused) q = data.filters?.q || '';
from = data.filters?.from || '';
to = data.filters?.to || '';
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
tagNames = data.filters?.tags || [];
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
});
</script>
<!-- Outer Container: Matches the 'Sand' background of the layout -->
<main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
<!-- SEARCH & FILTER CARD -->
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
<!-- ROW 1: Main Search (One Line) -->
<div class="flex items-center gap-4">
<!-- Full Text Search -->
<div class="relative flex-1">
<input
type="text"
bind:value={q}
oninput={handleTextSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
placeholder={m.docs_search_placeholder()}
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-40"
/>
</div>
</div>
<!-- Toggle Advanced Button -->
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
/>
{m.docs_btn_filter()}
</button>
<!-- Reset Button -->
<a
href="/"
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-ink-3 transition hover:text-red-500"
title={m.docs_btn_reset_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-40"
/>
</a>
</div>
<!-- ROW 2: Advanced Filters (Collapsible) -->
{#if showAdvanced}
<div
transition:slide
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
>
<!-- Tag Filter -->
<div class="md:col-span-12">
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.docs_filter_label_tags()}
</p>
<TagInput bind:tags={tagNames} allowCreation={false} />
</div>
<!-- Sender -->
<div class="md:col-span-3">
<div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={data.initialValues?.senderName}
onchange={triggerSearch}
/>
</div>
</div>
<!-- Receiver -->
<div class="md:col-span-3">
<div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={data.initialValues?.receiverName}
onchange={triggerSearch}
/>
</div>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4 md:col-span-6">
<div>
<label
for="from"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.docs_filter_label_from()}</label
>
<input
type="date"
id="from"
bind:value={from}
onchange={triggerSearch}
class="block w-full border-line py-2.5 text-sm shadow-sm"
/>
</div>
<div>
<label
for="to"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.docs_filter_label_to()}</label
>
<input
type="date"
id="to"
bind:value={to}
onchange={triggerSearch}
class="block w-full border-line py-2.5 text-sm shadow-sm"
/>
</div>
</div>
</div>
{/if}
</div>
{#if data.canWrite}
<!-- UPLOAD DROP ZONE -->
<div
role="button"
tabindex="0"
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 py-3 text-sm transition-colors duration-150 {isDragging
? 'border-accent bg-accent/5 text-accent'
: 'border-line-2 text-ink-3 hover:border-accent hover:text-accent'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Upload/Upload-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 shrink-0 opacity-50"
/>
<span class="font-sans font-medium">
{isUploading ? '…' : m.upload_drop_hint()}
</span>
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
</div>
{#if uploadMessages.length > 0}
<div class="mb-4 flex flex-col gap-1">
{#each uploadMessages as msg, i (i)}
<p class="font-sans text-sm {msg.isError ? 'text-red-600' : 'text-green-700'}">
{msg.text}
</p>
{/each}
</div>
{/if}
{/if}
<!-- DOCUMENT LIST HEADER -->
<div class="mb-2 flex justify-end">
{#if data.canWrite}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 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>
{/if}
</div>
<!-- DOCUMENT LIST -->
<div class="border border-line bg-surface shadow-sm">
{#if data.error}
<div class="bg-red-50 p-8 text-center text-red-600">
{data.error}
</div>
{:else if data.documents && data.documents.length > 0}
<ul class="divide-y divide-line-2">
{#each data.documents as doc (doc.id)}
<li class="group transition-colors duration-200 hover:bg-muted/50">
<!-- LINK TO DETAIL PAGE -->
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
<!-- Main Info -->
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<!-- Title: Serif & Brand Navy -->
<h3
class="font-serif text-xl font-medium text-ink decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
>
{doc.title || doc.originalFilename}
</h3>
</div>
<!-- Metadata Row -->
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-ink-2">
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
{#if doc.location}
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.location}
</div>
{/if}
</div>
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_to()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
</div>
<!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white"
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Arrow Icon -->
<div
class="hidden items-center text-ink-3 transition-colors group-hover:text-accent sm:flex"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{:else}
<!-- Empty State -->
<div class="p-16 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
<p class="mt-1 font-sans text-sm text-ink-2">
{m.docs_empty_text()}
</p>
<button
onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-accent uppercase transition hover:text-ink"
>
{m.docs_empty_btn_clear()}
</button>
</div>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
class="sr-only"
onchange={handleFileSelect}
/>
</main>