feat(upload): add drag-and-drop bulk upload zone to home page
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
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
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>
This commit is contained in:
@@ -265,5 +265,10 @@
|
|||||||
"doc_panel_annotation_thread_title": "Annotation",
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||||
"pdf_annotations_hide": "Annotierungen verbergen"
|
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||||
|
"upload_drop_hint": "Dateien ablegen oder auswählen",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} Dokument(e) erstellt",
|
||||||
|
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||||
|
"upload_error": "Fehler beim Hochladen von {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,5 +265,10 @@
|
|||||||
"doc_panel_annotation_thread_title": "Annotation",
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||||
"pdf_annotations_show": "Show annotations",
|
"pdf_annotations_show": "Show annotations",
|
||||||
"pdf_annotations_hide": "Hide annotations"
|
"pdf_annotations_hide": "Hide annotations",
|
||||||
|
"upload_drop_hint": "Drop files or click to select",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} document(s) created",
|
||||||
|
"upload_invalid_type": "{filename}: unsupported file format",
|
||||||
|
"upload_error": "Error uploading {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,5 +265,10 @@
|
|||||||
"doc_panel_annotation_thread_title": "Anotación",
|
"doc_panel_annotation_thread_title": "Anotación",
|
||||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||||
"pdf_annotations_show": "Mostrar anotaciones",
|
"pdf_annotations_show": "Mostrar anotaciones",
|
||||||
"pdf_annotations_hide": "Ocultar anotaciones"
|
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||||
|
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_success": "{count} documento(s) creado(s)",
|
||||||
|
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||||
|
"upload_error": "Error al subir {filename}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -18,6 +18,13 @@ let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
|||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
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>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||||
@@ -29,6 +36,81 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
|||||||
|
|
||||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
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() {
|
function triggerSearch() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
|
|
||||||
@@ -210,6 +292,43 @@ $effect(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- DOCUMENT LIST HEADER -->
|
||||||
<div class="mb-2 flex justify-end">
|
<div class="mb-2 flex justify-end">
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
@@ -360,4 +479,12 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
|
||||||
|
class="sr-only"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user