Phase 03: Images - 4 plan(s) in 3 wave(s) - Wave 1: 03-01 (foundation) - Wave 2: 03-02, 03-03 (parallel - upload + camera) - Wave 3: 03-04 (integration + verification) - Ready for execution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
285 lines
8.8 KiB
Markdown
285 lines
8.8 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/tho/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Upload form action with thumbnail generation</name>
|
|
<files>
|
|
src/routes/+page.server.ts
|
|
</files>
|
|
<action>
|
|
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)
|
|
</action>
|
|
<verify>
|
|
Run `npm run check` - TypeScript compiles.
|
|
Verify action exists: `grep -n "uploadImage:" src/routes/+page.server.ts`
|
|
</verify>
|
|
<done>
|
|
uploadImage action handles file upload, generates thumbnail, saves to database.
|
|
load function returns entries with their images attached.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: ImageUpload component with drag-drop</name>
|
|
<files>
|
|
src/lib/components/ImageUpload.svelte
|
|
</files>
|
|
<action>
|
|
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])
|
|
</action>
|
|
<verify>
|
|
Run `npm run check` - TypeScript compiles.
|
|
File exists with core functions: `grep -c "handleDrop\|uploadFile" src/lib/components/ImageUpload.svelte`
|
|
</verify>
|
|
<done>
|
|
ImageUpload component renders drag-drop zone with button fallback.
|
|
Shows optimistic preview during upload.
|
|
Calls form action and refreshes list on completion.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Integrate upload into QuickCapture</name>
|
|
<files>
|
|
src/lib/components/QuickCapture.svelte
|
|
</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
Run `npm run check` - TypeScript compiles.
|
|
Run `npm run dev` and verify QuickCapture shows image button.
|
|
</verify>
|
|
<done>
|
|
QuickCapture has image attachment button.
|
|
User can add image before submitting new entry.
|
|
Image uploads after entry creation with the new entry ID.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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;"`
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/03-images/03-02-SUMMARY.md`
|
|
</output>
|