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:
164
src/lib/components/ImageUpload.svelte
Normal file
164
src/lib/components/ImageUpload.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user