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