Optional callback lets the parent route pop a post-upload banner without lifting state into a store. Dashboard uses it to drive UploadSuccessBanner (issue #296). Only fires when the server actually created new documents — duplicates and errors do not trigger it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
5.8 KiB
Svelte
216 lines
5.8 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'];
|
|
|
|
interface Props {
|
|
onUploadComplete?: (count: number) => void;
|
|
}
|
|
|
|
let { onUploadComplete }: Props = $props();
|
|
|
|
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 });
|
|
onUploadComplete?.(result.created.length);
|
|
}
|
|
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}
|
|
/>
|