feat(03-02): create ImageUpload component with drag-drop

- Add drag-and-drop zone for image uploads
- Show optimistic preview during upload
- Support keyboard navigation (Enter/Space to trigger)
- Handle upload via fetch to ?/uploadImage action
- Clean up object URLs after upload completes
- Provide callbacks for upload lifecycle events
This commit is contained in:
Thomas Richter
2026-01-29 15:27:48 +01:00
parent de3aa5ac4e
commit a35b07e45e

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
interface Props {
entryId: string;
onUploadStart?: () => void;
onUploadComplete?: (imageId: string) => void;
onUploadError?: (error: string) => void;
}
let { entryId, onUploadStart, onUploadComplete, onUploadError }: Props = $props();
let isDragging = $state(false);
let isUploading = $state(false);
let previewUrl: string | null = $state(null);
let fileInput: HTMLInputElement;
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDragging = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) {
uploadFile(file);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
uploadFile(file);
}
}
async function uploadFile(file: File) {
// Validate image type
if (!file.type.startsWith('image/')) {
onUploadError?.('File must be an image');
return;
}
// Create optimistic preview
previewUrl = URL.createObjectURL(file);
onUploadStart?.();
isUploading = true;
try {
const formData = new FormData();
formData.append('image', file);
formData.append('entryId', entryId);
const response = await fetch('?/uploadImage', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.type === 'failure') {
const errorMsg = result.data?.error || 'Upload failed';
onUploadError?.(errorMsg);
} else if (result.type === 'success') {
onUploadComplete?.(result.data?.imageId);
await invalidateAll();
}
} catch (err) {
console.error('Upload error:', err);
onUploadError?.('Network error during upload');
} finally {
// Cleanup
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
isUploading = false;
// Reset file input
if (fileInput) {
fileInput.value = '';
}
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput?.click();
}
}
</script>
<div
class="relative border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer min-h-[100px] flex items-center justify-center {isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="button"
tabindex="0"
onclick={() => fileInput?.click()}
onkeydown={handleKeydown}
>
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileSelect}
class="hidden"
/>
{#if isUploading}
<div class="flex flex-col items-center gap-2">
{#if previewUrl}
<img src={previewUrl} alt="Preview" class="w-16 h-16 object-cover rounded opacity-50" />
{/if}
<div class="flex items-center gap-2 text-gray-500">
<svg
class="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="text-sm">Uploading...</span>
</div>
</div>
{:else}
<div class="flex flex-col items-center gap-2 text-gray-500">
<svg
class="w-8 h-8"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="text-sm">Drop image or click to upload</span>
</div>
{/if}
</div>