Files
bea-edit/src/lib/components/FileDropzone.svelte
T
rehlert 09cc5c06fb
Build and Push Docker Image / build (push) Successful in 1m26s
rotate + hover on start-page
2026-08-29 12:04:29 +02:00

140 lines
3.9 KiB
Svelte

<script lang="ts">
import { Archive } from '@lucide/svelte';
type Props = {
onFilesSelected: (files: File[]) => void;
onFilesRejected?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
ariaLabel?: string;
label?: string;
sublabel?: string;
icon?: typeof Archive;
class?: string;
};
let {
onFilesSelected,
onFilesRejected,
accept = '.pdf,application/pdf',
multiple = false,
ariaLabel = 'PDF-Dateien hinzufügen',
label = 'Dateien hinzufügen',
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
icon = undefined,
class: className = ''
}: Props = $props();
let fileInput = $state<HTMLInputElement | null>(null);
let isDragging = $state(false);
let rejectionMessage = $state<string | null>(null);
let dragDepth = 0;
const matchesAccept = (file: File) =>
accept
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
.some((filter) => {
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
return file.type.toLowerCase() === filter;
});
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const received = Array.from(files);
const accepted = received.filter(matchesAccept);
const rejected = received.filter((file) => !matchesAccept(file));
if (rejected.length > 0) {
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
onFilesRejected?.(rejected);
}
if (accepted.length > 0) {
rejectionMessage = null;
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = '';
};
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
dragDepth += 1;
isDragging = true;
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragDepth = 0;
isDragging = false;
processFiles(event.dataTransfer?.files);
};
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
{accept}
{multiple}
onchange={handleFileSelection}
/>
<button
type="button"
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 py-10 text-center transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
? 'scale-[1.02] border-primary bg-primary-100/70 shadow-lg ring-4 ring-primary/25'
: 'border-primary-300 bg-white hover:border-primary-400 hover:bg-primary-50/60'} {className}"
aria-label={ariaLabel}
data-dropzone-ready={fileInput ? 'true' : 'false'}
onclick={openFileDialog}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if icon}
{@const IconComponent = icon}
<IconComponent
class="h-10 w-10 text-primary transition-transform duration-150 {isDragging
? 'scale-125'
: ''}"
/>
{/if}
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[18px] font-bold text-primary-900">{label}</span>
<span class="text-[20px] font-extrabold text-primary">+</span>
</div>
{#if sublabel}
<p class="text-[13px] text-primary-700">{sublabel}</p>
{/if}
{#if isDragging}
<p class="text-[13px] font-bold text-primary">Jetzt loslassen zum Hinzufügen</p>
{/if}
</button>
{#if rejectionMessage}
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
{/if}