Files
familienarchiv/frontend/src/routes/DropZone.svelte
Marcel e7829312e8
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Failing after 2m23s
CI / Unit & Component Tests (push) Has been cancelled
CI / E2E Tests (push) Failing after 3h0m36s
fix: use existing doc_file_upload_label key in DropZone aria-label
upload_label was referenced but never added to messages — caused a
500 on every page render. Reuses the existing doc_file_upload_label
key ("Datei hochladen" / "Upload file") which has the same meaning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:12:42 +01:00

209 lines
5.6 KiB
Svelte

<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/errors';
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
let isDragging = $state(false);
let windowDragging = $state(false);
let dragCounter = 0;
let isUploading = $state(false);
let uploadProgress = $state(0);
let uploadMessages = $state<{ text: string; isError: boolean; link?: string }[]>([]);
let fileInput: HTMLInputElement;
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
windowDragging = false;
dragCounter = 0;
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; link?: string }[] = [];
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;
uploadProgress = 0;
try {
const formData = new FormData();
for (const file of valid) {
formData.append('files', file);
}
const { ok, body } = await new Promise<{ ok: boolean; body: string }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/documents/quick-upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) uploadProgress = Math.round((e.loaded / e.total) * 100);
});
xhr.addEventListener('load', () => resolve({ ok: xhr.status < 300, body: xhr.responseText }));
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.send(formData);
});
if (ok) {
const result = JSON.parse(body);
if (result.created?.length > 0) {
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
}
for (const doc of result.updated ?? []) {
messages.push({
text: m.upload_duplicate({ filename: doc.originalFilename }),
isError: false,
link: `/documents/${doc.id}`
});
}
for (const err of result.errors ?? []) {
messages.push({
text: `${err.filename}: ${getErrorMessage(err.code)}`,
isError: true
});
}
await invalidateAll();
} else {
for (const file of valid) {
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
}
}
} finally {
isUploading = false;
uploadProgress = 0;
uploadMessages = messages;
}
}
$effect(() => {
function onWindowDragEnter(e: DragEvent) {
if (!e.dataTransfer?.types.includes('Files')) return;
dragCounter++;
windowDragging = true;
}
function onWindowDragLeave() {
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
windowDragging = false;
}
}
function onWindowDrop() {
dragCounter = 0;
windowDragging = false;
}
window.addEventListener('dragenter', onWindowDragEnter);
window.addEventListener('dragleave', onWindowDragLeave);
window.addEventListener('drop', onWindowDrop);
return () => {
window.removeEventListener('dragenter', onWindowDragEnter);
window.removeEventListener('dragleave', onWindowDragLeave);
window.removeEventListener('drop', onWindowDrop);
};
});
</script>
<div
role="button"
tabindex="0"
class="mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 border border-dashed px-6 transition-all duration-200 {isDragging
? 'border-primary bg-accent-bg py-10 text-primary'
: windowDragging
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
: 'border-ink/30 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
>
{#if isUploading}
<div class="flex w-48 flex-col items-center gap-1">
<div class="h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
<div
class="h-full rounded-full bg-primary transition-all duration-200"
style="width: {uploadProgress}%"
></div>
</div>
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
</div>
{:else}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
alt=""
aria-hidden="true"
class="h-8 w-8 opacity-40"
/>
<div class="flex flex-col items-center gap-0.5 text-center">
<span class="font-sans text-sm text-ink-2">{m.upload_drop_hint()}</span>
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
<span class="font-sans text-xs text-ink-3 italic">{m.upload_filename_hint()}</span>
</div>
{/if}
</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'
: msg.link
? 'text-amber-700'
: 'text-green-700'}"
>
{msg.text}
{#if msg.link}
<a href={msg.link} class="underline hover:no-underline">{m.upload_duplicate_link()}</a>
{/if}
</p>
{/each}
</div>
{/if}
<input
bind:this={fileInput}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
aria-label={m.doc_file_upload_label()}
class="sr-only"
onchange={handleFileSelect}
/>