--- phase: 03-images plan: 02 type: execute wave: 2 depends_on: ["03-01"] files_modified: - src/routes/+page.server.ts - src/lib/components/ImageUpload.svelte - src/lib/components/QuickCapture.svelte autonomous: true must_haves: truths: - "User can upload an image via file picker" - "User can upload an image via drag-and-drop" - "Uploaded image appears immediately (optimistic preview)" - "Image is stored on server with thumbnail generated" artifacts: - path: "src/routes/+page.server.ts" provides: "uploadImage form action" contains: "uploadImage:" - path: "src/lib/components/ImageUpload.svelte" provides: "Upload button + drag-drop zone" min_lines: 50 key_links: - from: "src/lib/components/ImageUpload.svelte" to: "src/routes/+page.server.ts" via: "fetch to ?/uploadImage" pattern: "fetch.*\\?/uploadImage" - from: "src/routes/+page.server.ts" to: "src/lib/server/images/storage.ts" via: "saveOriginal import" pattern: "import.*storage" --- Implement file upload functionality with drag-and-drop support and optimistic preview. Purpose: Enables users to attach images to entries via file selection or drag-and-drop on desktop. This is the primary upload path for desktop users. Output: - Form action that handles multipart file uploads - ImageUpload component with button and drag-drop zone - Integration with QuickCapture for upload during entry creation @/home/tho/.claude/get-shit-done/workflows/execute-plan.md @/home/tho/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/03-images/03-RESEARCH.md @.planning/phases/03-images/03-01-SUMMARY.md @src/routes/+page.server.ts @src/lib/components/QuickCapture.svelte Task 1: Upload form action with thumbnail generation src/routes/+page.server.ts Add uploadImage action to existing +page.server.ts actions: 1. Import at top: - import { imageRepository } from '$lib/server/db/repository' - import { saveOriginal, saveThumbnail, ensureDirectories } from '$lib/server/images/storage' - import { generateThumbnail } from '$lib/server/images/thumbnails' - import { nanoid } from 'nanoid' 2. Add uploadImage action: ```typescript uploadImage: async ({ request }) => { const formData = await request.formData(); const file = formData.get('image') as File; const entryId = formData.get('entryId') as string; if (!file || file.size === 0) { return fail(400, { error: 'No file uploaded' }); } if (!entryId) { return fail(400, { error: 'Entry ID required' }); } // Verify entry exists const entry = entryRepository.getById(entryId); if (!entry) { return fail(404, { error: 'Entry not found' }); } // Validate image type if (!file.type.startsWith('image/')) { return fail(400, { error: 'File must be an image' }); } const imageId = nanoid(); const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; const buffer = Buffer.from(await file.arrayBuffer()); try { await ensureDirectories(); // Save original await saveOriginal(imageId, ext, buffer); // Generate and save thumbnail const thumbnailBuffer = await generateThumbnail(buffer); await saveThumbnail(imageId, thumbnailBuffer); // Save to database imageRepository.create({ entryId, filename: file.name, ext }); return { success: true, imageId }; } catch (err) { console.error('Upload error:', err); return fail(500, { error: 'Failed to save image' }); } } ``` 3. Update the load function to include images for each entry: - After getting entries, for each entry fetch its images via imageRepository.getByEntryId(entry.id) - Return entries with their images attached (map over entries) Run `npm run check` - TypeScript compiles. Verify action exists: `grep -n "uploadImage:" src/routes/+page.server.ts` uploadImage action handles file upload, generates thumbnail, saves to database. load function returns entries with their images attached. Task 2: ImageUpload component with drag-drop src/lib/components/ImageUpload.svelte Create ImageUpload.svelte component: Props: - entryId: string (required) - onUploadStart?: () => void (optional callback for optimistic UI) - onUploadComplete?: (imageId: string) => void (optional callback) - onUploadError?: (error: string) => void (optional callback) State: - isDragging: boolean ($state) - isUploading: boolean ($state) - previewUrl: string | null ($state) - for optimistic preview - fileInput: HTMLInputElement reference Functions: - handleDragOver(e: DragEvent): preventDefault, set isDragging true - handleDragLeave(e: DragEvent): preventDefault, set isDragging false - handleDrop(e: DragEvent): preventDefault, isDragging false, get first file, call uploadFile - handleFileSelect(e: Event): get file from input, call uploadFile - uploadFile(file: File): 1. Validate file.type starts with 'image/' 2. Create optimistic preview: previewUrl = URL.createObjectURL(file) 3. Call onUploadStart?.() 4. Set isUploading true 5. Create FormData with 'image' and 'entryId' 6. fetch('?/uploadImage', { method: 'POST', body: formData }) 7. Parse response, handle errors 8. Call onUploadComplete or onUploadError 9. Revoke preview URL, clear state 10. Call invalidateAll() to refresh list Template: - Drop zone div with: - Border dashed when not dragging, solid blue when dragging - ondragover, ondragleave, ondrop handlers - role="button", tabindex="0" - onclick opens file input - onkeydown Enter opens file input - Hidden file input with accept="image/*" - Show upload icon and "Drop image or click to upload" text - Show spinner when isUploading - Show optimistic preview image if previewUrl Styling (Tailwind): - Rounded border, dashed gray - Blue border/bg when dragging - Centered content with icon - Touch-friendly size (min-h-[100px]) Run `npm run check` - TypeScript compiles. File exists with core functions: `grep -c "handleDrop\|uploadFile" src/lib/components/ImageUpload.svelte` ImageUpload component renders drag-drop zone with button fallback. Shows optimistic preview during upload. Calls form action and refreshes list on completion. Task 3: Integrate upload into QuickCapture src/lib/components/QuickCapture.svelte Update QuickCapture.svelte to include image upload option: 1. Add state for pending image: - pendingImage: File | null ($state) - pendingPreviewUrl: string | null ($state) 2. Add hidden file input with accept="image/*" after the form 3. Add image upload button next to the submit button: - Icon-only button (camera/image icon) - Opens file picker on click - Disabled when already has pending image 4. When file selected: - Store in pendingImage - Create preview URL for display - Show small thumbnail preview next to input 5. Modify form submission flow: - If pendingImage exists: a. First submit the entry via existing create action b. Get the new entry ID from response c. Then upload the image with that entry ID d. Revoke preview URL e. Clear pendingImage 6. Add cancel button to remove pending image before submit 7. Show thumbnail preview (small, ~40px) inline when image is pending Keep the quick capture bar clean - image attachment is optional, not prominent. Run `npm run check` - TypeScript compiles. Run `npm run dev` and verify QuickCapture shows image button. QuickCapture has image attachment button. User can add image before submitting new entry. Image uploads after entry creation with the new entry ID. 1. `npm run check` passes 2. `npm run dev` - server starts 3. Test file upload: - Create a new entry - Expand entry and look for upload option (will be added in 03-04) - OR test via QuickCapture image button 4. Check upload directories created: `ls data/uploads/` 5. Check image in database: `sqlite3 data/taskplaner.db "SELECT * FROM images LIMIT 5;"` - Form action accepts multipart file upload - Thumbnails generated with Sharp (EXIF rotation handled) - ImageUpload component provides drag-drop and button interfaces - Optimistic preview shows image immediately - QuickCapture allows attaching image to new entry After completion, create `.planning/phases/03-images/03-02-SUMMARY.md`