docs(03): create phase plan

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>
This commit is contained in:
Thomas Richter
2026-01-29 15:15:30 +01:00
parent d99bd0d3a2
commit 04c2742e73
5 changed files with 1191 additions and 5 deletions

View File

@@ -68,12 +68,13 @@ Plans:
3. User can view attached images inline with entry
4. User can remove image attachments from an entry
5. Images are stored on filesystem (not in database)
**Plans**: TBD
**Plans**: 4 plans
Plans:
- [ ] 03-01: TBD
- [ ] 03-02: TBD
- [ ] 03-03: TBD
- [ ] 03-01-PLAN.md — Database schema, file storage, thumbnail generation, and API endpoints
- [ ] 03-02-PLAN.md — File upload form action and ImageUpload component with drag-drop
- [ ] 03-03-PLAN.md — CameraCapture component with getUserMedia and preview/confirm flow
- [ ] 03-04-PLAN.md — EntryCard integration with gallery, lightbox, and delete functionality
### Phase 4: Tags & Organization
**Goal**: Users can organize entries with tags and quick access features
@@ -136,7 +137,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6
|-------|----------------|--------|-----------|
| 1. Foundation | 2/2 | Complete ✓ | 2026-01-29 |
| 2. Core CRUD | 4/4 | Complete ✓ | 2026-01-29 |
| 3. Images | 0/3 | Not started | - |
| 3. Images | 0/4 | Ready | - |
| 4. Tags & Organization | 0/3 | Not started | - |
| 5. Search | 0/3 | Not started | - |
| 6. Deployment | 0/2 | Not started | - |

View File

@@ -0,0 +1,224 @@
---
phase: 03-images
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/lib/server/db/schema.ts
- src/lib/server/db/repository.ts
- src/lib/server/images/storage.ts
- src/lib/server/images/thumbnails.ts
- src/routes/api/images/[id]/+server.ts
- src/routes/api/images/[id]/thumbnail/+server.ts
autonomous: true
must_haves:
truths:
- "Images table exists in database with entryId foreign key"
- "Image files can be written to filesystem"
- "Thumbnails can be generated from uploaded images"
- "Images can be served via API endpoints"
artifacts:
- path: "src/lib/server/db/schema.ts"
provides: "images table definition"
contains: "images = sqliteTable"
- path: "src/lib/server/db/repository.ts"
provides: "imageRepository with CRUD operations"
contains: "imageRepository"
- path: "src/lib/server/images/storage.ts"
provides: "File write/read operations"
exports: ["saveOriginal", "saveThumbnail", "deleteImage"]
- path: "src/lib/server/images/thumbnails.ts"
provides: "Sharp thumbnail generation"
exports: ["generateThumbnail"]
- path: "src/routes/api/images/[id]/+server.ts"
provides: "GET endpoint for original images"
exports: ["GET"]
- path: "src/routes/api/images/[id]/thumbnail/+server.ts"
provides: "GET endpoint for thumbnails"
exports: ["GET"]
key_links:
- from: "src/lib/server/images/thumbnails.ts"
to: "sharp"
via: "import sharp"
pattern: "import sharp from 'sharp'"
- from: "src/routes/api/images/[id]/+server.ts"
to: "src/lib/server/db/repository.ts"
via: "imageRepository.getById"
pattern: "imageRepository\\.getById"
---
<objective>
Create the foundation layer for image attachments: database schema, file storage utilities, thumbnail generation, and API endpoints for serving images.
Purpose: Establishes the data model and infrastructure that upload components will use. Without this layer, images cannot be stored, processed, or retrieved.
Output:
- Database table for images with entry relationship
- Repository layer for image CRUD
- Filesystem utilities for storing originals and thumbnails
- API endpoints that serve images to the browser
</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/ROADMAP.md
@.planning/phases/03-images/03-RESEARCH.md
@src/lib/server/db/schema.ts
@src/lib/server/db/repository.ts
@src/lib/server/db/index.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Database schema and repository for images</name>
<files>
src/lib/server/db/schema.ts
src/lib/server/db/repository.ts
</files>
<action>
1. Install sharp: `npm install sharp`
2. Add images table to schema.ts:
- id: text primary key (nanoid)
- entryId: text foreign key to entries.id (not null)
- filename: text (original filename for display)
- ext: text (file extension without dot: jpg, png, etc.)
- createdAt: text ISO timestamp
Export Image and NewImage types.
3. Add ImageRepository interface and implementation to repository.ts:
- create(image: Omit<NewImage, 'id' | 'createdAt'>): Image
- getById(id: string): Image | undefined
- getByEntryId(entryId: string): Image[]
- delete(id: string): boolean
- deleteByEntryId(entryId: string): number (returns count deleted)
4. Export imageRepository singleton alongside entryRepository.
Use same patterns as existing entryRepository (nanoid for IDs, synchronous Drizzle operations).
</action>
<verify>
Run `npm run check` - TypeScript compiles without errors.
Verify exports: `grep -n "imageRepository" src/lib/server/db/repository.ts`
</verify>
<done>
images table defined with entryId relationship.
imageRepository exported with create, getById, getByEntryId, delete, deleteByEntryId methods.
</done>
</task>
<task type="auto">
<name>Task 2: File storage and thumbnail generation utilities</name>
<files>
src/lib/server/images/storage.ts
src/lib/server/images/thumbnails.ts
</files>
<action>
1. Create src/lib/server/images/storage.ts:
- UPLOAD_DIR constant: 'data/uploads'
- ORIGINALS_DIR: 'data/uploads/originals'
- THUMBNAILS_DIR: 'data/uploads/thumbnails'
Functions:
- ensureDirectories(): Creates directories if they don't exist (use mkdir recursive)
- saveOriginal(id: string, ext: string, buffer: Buffer): Promise<void>
Writes to `${ORIGINALS_DIR}/${id}.${ext}`
- saveThumbnail(id: string, buffer: Buffer): Promise<void>
Writes to `${THUMBNAILS_DIR}/${id}.jpg` (thumbnails always jpg)
- getOriginalPath(id: string, ext: string): string
- getThumbnailPath(id: string): string
- deleteImage(id: string, ext: string): Promise<void>
Removes both original and thumbnail files (use try/catch, don't throw if missing)
2. Create src/lib/server/images/thumbnails.ts:
- import sharp from 'sharp'
Function:
- generateThumbnail(buffer: Buffer, size?: number): Promise<Buffer>
Default size: 150
CRITICAL: Call sharp.rotate() FIRST to handle EXIF orientation from mobile photos
Then resize with fit: 'cover', position: 'center', withoutEnlargement: true
Output as jpeg with quality 80
Return the thumbnail buffer (caller saves it)
</action>
<verify>
Run `npm run check` - TypeScript compiles.
Run quick test: `node -e "import('sharp').then(s => console.log('sharp version:', s.default.versions?.sharp || 'loaded'))"` or just verify sharp installed in package.json.
</verify>
<done>
storage.ts exports directory management and file I/O functions.
thumbnails.ts exports generateThumbnail that handles EXIF rotation.
</done>
</task>
<task type="auto">
<name>Task 3: API endpoints for serving images</name>
<files>
src/routes/api/images/[id]/+server.ts
src/routes/api/images/[id]/thumbnail/+server.ts
</files>
<action>
1. Create src/routes/api/images/[id]/+server.ts:
- GET handler that:
a. Gets id from params
b. Looks up image in imageRepository.getById(id)
c. Returns 404 error if not found
d. Reads file from getOriginalPath(id, image.ext)
e. Returns Response with:
- Content-Type based on ext (jpg -> image/jpeg, png -> image/png, etc.)
- Content-Length header
- Cache-Control: public, max-age=31536000, immutable (images are immutable by ID)
f. Catches file read errors, returns 404
2. Create src/routes/api/images/[id]/thumbnail/+server.ts:
- Same pattern but reads from getThumbnailPath(id)
- Content-Type always image/jpeg (thumbnails are always jpg)
Import imageRepository and storage functions. Use readFile from 'node:fs/promises'.
</action>
<verify>
Run `npm run check` - TypeScript compiles.
Verify endpoints exist: `ls -la src/routes/api/images/`
</verify>
<done>
GET /api/images/[id] serves original images with proper Content-Type.
GET /api/images/[id]/thumbnail serves thumbnails.
Both return 404 for missing images.
</done>
</task>
</tasks>
<verification>
1. `npm run check` passes with no TypeScript errors
2. `npm run dev` starts without errors
3. Sharp is in dependencies: `grep sharp package.json`
4. All new files exist:
- src/lib/server/db/schema.ts contains `images = sqliteTable`
- src/lib/server/db/repository.ts exports `imageRepository`
- src/lib/server/images/storage.ts exists
- src/lib/server/images/thumbnails.ts exists
- src/routes/api/images/[id]/+server.ts exists
- src/routes/api/images/[id]/thumbnail/+server.ts exists
</verification>
<success_criteria>
- images table can store image metadata with entry relationship
- imageRepository provides typed CRUD for images
- Thumbnail generation uses Sharp with EXIF auto-rotation
- API endpoints can serve images from filesystem
- All code compiles without TypeScript errors
</success_criteria>
<output>
After completion, create `.planning/phases/03-images/03-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,284 @@
---
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>

View File

@@ -0,0 +1,263 @@
---
phase: 03-images
plan: 03
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- src/lib/components/CameraCapture.svelte
autonomous: true
must_haves:
truths:
- "User can open camera modal"
- "User can see live camera preview"
- "User can capture a photo"
- "User can preview captured photo before confirming"
- "User can retake if not satisfied"
- "Camera stream stops when modal closes"
artifacts:
- path: "src/lib/components/CameraCapture.svelte"
provides: "Camera capture modal with preview and confirm"
min_lines: 100
key_links:
- from: "src/lib/components/CameraCapture.svelte"
to: "navigator.mediaDevices.getUserMedia"
via: "browser API call"
pattern: "getUserMedia"
- from: "src/lib/components/CameraCapture.svelte"
to: "canvas.toBlob"
via: "photo capture"
pattern: "toBlob"
---
<objective>
Implement camera capture component for mobile photo taking with preview and confirm flow.
Purpose: Enables users to take photos directly in the app, critical for the core use case of photographing paper notes. Direct camera access feels more native than file picker on mobile.
Output:
- CameraCapture modal component with live preview
- Capture, preview, retake, confirm flow
- Proper stream cleanup to prevent battery drain
</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
</context>
<tasks>
<task type="auto">
<name>Task 1: CameraCapture component with getUserMedia</name>
<files>
src/lib/components/CameraCapture.svelte
</files>
<action>
Create CameraCapture.svelte - a modal for camera capture:
Props:
- entryId: string (required)
- onClose: () => void (required - parent controls open state)
- onCapture: (imageId: string) => void (optional callback after upload)
State:
- stream: MediaStream | null ($state)
- capturedBlob: Blob | null ($state)
- capturedPreviewUrl: string | null ($state)
- error: string | null ($state)
- isUploading: boolean ($state)
- facingMode: 'environment' | 'user' ($state) - default 'environment' (back camera)
Element refs:
- videoElement: HTMLVideoElement
- canvasElement: HTMLCanvasElement
Functions:
1. startCamera():
- Check navigator.mediaDevices?.getUserMedia exists, set error if not
- Stop existing stream if any
- Try getUserMedia with: video: { facingMode, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false
- Catch errors:
- NotAllowedError: "Camera permission denied"
- NotFoundError: "No camera found"
- Other: "Could not access camera"
- Set stream, assign to videoElement.srcObject, call play()
2. stopCamera():
- stream?.getTracks().forEach(track => track.stop())
- stream = null
- videoElement.srcObject = null
3. capturePhoto():
- Get 2D context from canvas
- Set canvas dimensions to video dimensions
- drawImage(videoElement, 0, 0)
- canvas.toBlob() with 'image/jpeg', 0.9 quality
- Store blob, create preview URL
- Stop camera (save battery while reviewing)
4. retake():
- Revoke preview URL
- Clear capturedBlob and capturedPreviewUrl
- startCamera() again
5. confirmAndUpload():
- isUploading = true
- Create FormData with blob as 'image' (filename: 'camera-capture.jpg')
- Add entryId
- fetch('?/uploadImage', { method: 'POST', body: formData })
- Handle response
- Call onCapture, onClose
- Revoke preview URL, clean up
6. switchCamera():
- Toggle facingMode
- Restart camera with new facing mode
7. handleClose():
- stopCamera()
- Revoke any preview URL
- onClose()
Lifecycle:
- $effect: startCamera() on mount
- $effect: cleanup on unmount (stopCamera)
Template structure:
```
<div class="fixed inset-0 z-50 bg-black flex flex-col">
<!-- Header with close button and switch camera button -->
<div class="flex justify-between p-4">
<button close>X</button>
<button switchCamera>Flip</button>
</div>
<!-- Main content area -->
<div class="flex-1 flex items-center justify-center">
{#if error}
<p class="text-white">{error}</p>
<button onclick={onClose}>Close</button>
{:else if capturedPreviewUrl}
<!-- Preview captured photo -->
<img src={capturedPreviewUrl} alt="Captured" class="max-h-full max-w-full object-contain" />
{:else}
<!-- Live camera view -->
<video bind:this={videoElement} autoplay playsinline muted class="max-h-full max-w-full object-contain" />
{/if}
</div>
<!-- Hidden canvas for capture -->
<canvas bind:this={canvasElement} class="hidden" />
<!-- Action buttons -->
<div class="p-4 flex justify-center gap-4">
{#if capturedPreviewUrl}
<button retake disabled={isUploading}>Retake</button>
<button confirm disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Use Photo'}
</button>
{:else if !error}
<button capture class="w-16 h-16 rounded-full bg-white">
<!-- Capture button - large circle -->
</button>
{/if}
</div>
</div>
```
CRITICAL: Always stop camera stream when component closes or captures. Camera indicator should not stay on.
Styling:
- Full screen black background (typical camera UI)
- Large touch targets for capture button (min 64px)
- Video fills available space while maintaining aspect ratio
</action>
<verify>
Run `npm run check` - TypeScript compiles.
File exists and has key functions: `grep -c "getUserMedia\|capturePhoto\|stopCamera" src/lib/components/CameraCapture.svelte`
</verify>
<done>
CameraCapture modal with live preview renders.
Capture creates blob from canvas.
Preview shows captured image with retake/confirm options.
Camera stream properly stopped on close and after capture.
</done>
</task>
<task type="auto">
<name>Task 2: Feature detection and graceful fallback</name>
<files>
src/lib/components/CameraCapture.svelte
</files>
<action>
Enhance CameraCapture with proper feature detection and error handling:
1. Add hasCameraSupport check:
```typescript
const hasCameraSupport = typeof navigator !== 'undefined' &&
!!navigator.mediaDevices?.getUserMedia;
```
2. If no camera support, show friendly message immediately (don't try to call getUserMedia):
"Camera not supported in this browser. Use the file upload option instead."
3. Add isSecureContext check:
- Camera API requires HTTPS (except localhost)
- Show message if not secure: "Camera requires HTTPS connection"
4. Handle permission denied gracefully:
- Clear error message explaining how to enable camera
- "Camera access denied. Check browser settings to allow camera."
5. Handle no camera found:
- "No camera found on this device. Use file upload instead."
6. Add timeout for camera start:
- If camera doesn't start within 10 seconds, show error
- Prevents infinite loading state
7. Test on desktop without webcam - should show "No camera found" not crash
The error states should have a close button that calls onClose().
</action>
<verify>
Run `npm run check` - TypeScript compiles.
Grep for error handling: `grep -c "NotAllowedError\|NotFoundError\|hasCameraSupport" src/lib/components/CameraCapture.svelte`
</verify>
<done>
CameraCapture gracefully handles missing camera, permission denied, insecure context.
Error messages are user-friendly with action guidance.
</done>
</task>
</tasks>
<verification>
1. `npm run check` passes
2. `npm run dev` - server starts
3. Component file exists: `ls src/lib/components/CameraCapture.svelte`
4. Has required functions: grep for getUserMedia, stopCamera, capturePhoto, toBlob
5. Has error handling: grep for NotAllowedError, NotFoundError
</verification>
<success_criteria>
- CameraCapture opens with live camera preview
- Capture button takes photo and shows preview
- Retake returns to live camera
- Confirm uploads the photo
- Camera stream stops when modal closes
- Graceful error handling for permission denied, no camera, unsupported browser
</success_criteria>
<output>
After completion, create `.planning/phases/03-images/03-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,414 @@
---
phase: 03-images
plan: 04
type: execute
wave: 3
depends_on: ["03-02", "03-03"]
files_modified:
- src/lib/components/EntryCard.svelte
- src/lib/components/ImageGallery.svelte
- src/lib/components/ImageLightbox.svelte
- src/routes/+page.server.ts
- src/routes/+page.svelte
autonomous: false
must_haves:
truths:
- "User sees thumbnail on entry card when images are attached"
- "User sees horizontal scrolling gallery in expanded entry"
- "User can tap image to view fullscreen in lightbox"
- "User can delete images in edit mode"
- "User can add images to existing entries"
artifacts:
- path: "src/lib/components/EntryCard.svelte"
provides: "Entry card with image thumbnail and gallery"
contains: "ImageGallery"
- path: "src/lib/components/ImageGallery.svelte"
provides: "Horizontal scrolling image gallery"
min_lines: 40
- path: "src/lib/components/ImageLightbox.svelte"
provides: "Fullscreen image viewer"
min_lines: 30
- path: "src/routes/+page.server.ts"
provides: "deleteImage form action"
contains: "deleteImage:"
key_links:
- from: "src/lib/components/EntryCard.svelte"
to: "src/lib/components/ImageGallery.svelte"
via: "component import"
pattern: "import.*ImageGallery"
- from: "src/lib/components/ImageGallery.svelte"
to: "src/lib/components/ImageLightbox.svelte"
via: "lightbox trigger"
pattern: "ImageLightbox"
---
<objective>
Integrate image display and management into the entry card UI with gallery, lightbox, and delete functionality.
Purpose: Completes the image feature by letting users see their images within entries, view them fullscreen, and manage (add/delete) them.
Output:
- EntryCard shows thumbnail indicator when entry has images
- Expanded entry shows horizontal gallery with all images
- Clicking image opens fullscreen lightbox
- Edit mode shows delete buttons on images
- Upload controls available in expanded entry
</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-02-SUMMARY.md
@.planning/phases/03-images/03-03-SUMMARY.md
@src/lib/components/EntryCard.svelte
@src/routes/+page.server.ts
@src/routes/+page.svelte
</context>
<tasks>
<task type="auto">
<name>Task 1: ImageGallery and ImageLightbox components</name>
<files>
src/lib/components/ImageGallery.svelte
src/lib/components/ImageLightbox.svelte
</files>
<action>
1. Install svelte-lightbox: `npm install svelte-lightbox`
2. Create ImageGallery.svelte:
Props:
- images: Array<{ id: string, ext: string, filename: string }>
- entryId: string
- editMode?: boolean (default false)
- onDelete?: (imageId: string) => void
State:
- lightboxOpen: boolean ($state)
- lightboxIndex: number ($state)
Template:
```svelte
<div class="flex gap-2 overflow-x-auto py-2 -mx-2 px-2">
{#each images as image, i}
<div class="relative flex-shrink-0">
<button
type="button"
onclick={() => { lightboxIndex = i; lightboxOpen = true; }}
class="block"
>
<img
src="/api/images/{image.id}/thumbnail"
alt={image.filename}
class="w-20 h-20 object-cover rounded-lg"
/>
</button>
{#if editMode}
<button
type="button"
onclick={() => onDelete?.(image.id)}
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center"
aria-label="Delete image"
>
X
</button>
{/if}
</div>
{/each}
</div>
{#if lightboxOpen}
<ImageLightbox
images={images}
startIndex={lightboxIndex}
onClose={() => lightboxOpen = false}
/>
{/if}
```
3. Create ImageLightbox.svelte:
Props:
- images: Array<{ id: string, ext: string, filename: string }>
- startIndex: number
- onClose: () => void
Use svelte-lightbox or create simple custom lightbox:
- Full screen overlay (fixed inset-0 bg-black/90 z-50)
- Close button in corner
- Image centered, max-width/max-height to fit
- Left/right navigation if multiple images
- Keyboard support: Escape closes, Arrow keys navigate
- Click outside image closes
- Show image from /api/images/{id} (full size, not thumbnail)
Keep it simple - a modal with the full-size image. Touch gestures (swipe, pinch-zoom) are nice-to-have, not required.
</action>
<verify>
Run `npm run check` - TypeScript compiles.
Files exist: `ls src/lib/components/Image*.svelte`
</verify>
<done>
ImageGallery renders horizontal scrolling thumbnails.
Edit mode shows delete buttons.
Clicking image opens ImageLightbox.
ImageLightbox shows full-size image with close/navigation.
</done>
</task>
<task type="auto">
<name>Task 2: Integrate images into EntryCard</name>
<files>
src/lib/components/EntryCard.svelte
src/routes/+page.svelte
</files>
<action>
1. Update +page.svelte to pass images to EntryCard:
- The load function already returns entries with images attached (from 03-02)
- Ensure EntryCard receives entry.images
2. Update EntryCard.svelte:
Add to Props interface:
- entry should include images: Array<{ id: string, ext: string, filename: string }>
Add imports:
- import ImageGallery from './ImageGallery.svelte'
- import ImageUpload from './ImageUpload.svelte'
- import CameraCapture from './CameraCapture.svelte'
Add state:
- showCamera: boolean ($state) - controls camera modal
- editMode: boolean ($state) - toggle for showing delete buttons
In collapsed view (next to title/content):
- If entry.images?.length > 0, show small image indicator
- Could be first thumbnail tiny (24x24) or just an image count badge
In expanded view:
- Add ImageGallery component showing all images
- Add "Edit Images" toggle button to enable delete mode
- Add upload section with:
- ImageUpload component (drag-drop/button)
- Camera button that opens CameraCapture modal
```svelte
{#if expanded}
<div class="mt-4 pl-9 space-y-3">
<!-- Existing edit fields... -->
<!-- Images section -->
{#if entry.images?.length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700">Images</label>
<button
type="button"
onclick={() => editMode = !editMode}
class="text-sm text-blue-600"
>
{editMode ? 'Done' : 'Edit'}
</button>
</div>
<ImageGallery
images={entry.images}
entryId={entry.id}
editMode={editMode}
onDelete={handleDeleteImage}
/>
</div>
{/if}
<!-- Add image section -->
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">Add Image</label>
<div class="flex gap-2">
<ImageUpload entryId={entry.id} onUploadComplete={handleUploadComplete} />
<button
type="button"
onclick={() => showCamera = true}
class="px-4 py-2 border rounded-lg flex items-center gap-2"
>
Camera
</button>
</div>
</div>
<!-- Existing type selector and delete button... -->
</div>
{/if}
{#if showCamera}
<CameraCapture
entryId={entry.id}
onClose={() => showCamera = false}
onCapture={handleUploadComplete}
/>
{/if}
```
Add handler functions:
- handleDeleteImage(imageId: string): calls delete form action
- handleUploadComplete(): calls invalidateAll() to refresh
</action>
<verify>
Run `npm run check` - TypeScript compiles.
Run `npm run dev` - verify EntryCard shows images when expanded.
</verify>
<done>
EntryCard shows image indicator in collapsed view.
Expanded view shows ImageGallery with all images.
Edit mode reveals delete buttons on images.
Upload and camera buttons available for adding images.
</done>
</task>
<task type="auto">
<name>Task 3: Delete image form action</name>
<files>
src/routes/+page.server.ts
</files>
<action>
Add deleteImage action to +page.server.ts:
1. Import deleteImage from storage:
`import { deleteImage as deleteImageFile } from '$lib/server/images/storage'`
2. Add action:
```typescript
deleteImage: async ({ request }) => {
const formData = await request.formData();
const imageId = formData.get('imageId')?.toString();
if (!imageId) {
return fail(400, { error: 'Image ID required' });
}
// Get image to find extension for file deletion
const image = imageRepository.getById(imageId);
if (!image) {
return fail(404, { error: 'Image not found' });
}
// Delete files from filesystem
try {
await deleteImageFile(imageId, image.ext);
} catch (err) {
console.error('Failed to delete image files:', err);
// Continue to delete from database even if files missing
}
// Delete from database
imageRepository.delete(imageId);
return { success: true };
}
```
3. Wire up the handler in EntryCard:
```typescript
async function handleDeleteImage(imageId: string) {
const formData = new FormData();
formData.append('imageId', imageId);
await fetch('?/deleteImage', {
method: 'POST',
body: formData
});
await invalidateAll();
}
```
</action>
<verify>
Run `npm run check` - TypeScript compiles.
Verify action exists: `grep -n "deleteImage:" src/routes/+page.server.ts`
</verify>
<done>
deleteImage action removes image from database and filesystem.
EntryCard can delete images via the gallery edit mode.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Complete image attachment feature:
- File upload via drag-drop and button
- Camera capture on mobile
- Thumbnail display in entry cards
- Horizontal gallery in expanded view
- Fullscreen lightbox viewer
- Delete images in edit mode
</what-built>
<how-to-verify>
1. Start dev server: `npm run dev`
2. Open http://localhost:5173 in browser
Test file upload (desktop):
- Create a new entry
- Expand the entry
- Drag an image onto the upload zone OR click to select file
- Verify image appears in gallery after upload
- Verify thumbnail shows in collapsed entry view
Test camera capture (use mobile device or browser with webcam):
- Expand an entry
- Click "Camera" button
- Grant camera permission when prompted
- Verify live camera preview appears
- Take a photo, verify preview shows
- Try "Retake" - should return to live camera
- Click "Use Photo" - verify image uploads and appears in gallery
Test image viewing:
- Click on a thumbnail in the gallery
- Verify lightbox opens with full-size image
- Close lightbox (click X or outside image)
Test image deletion:
- Expand entry with images
- Click "Edit" button above gallery
- Verify delete buttons appear on images
- Delete an image
- Verify image removed from gallery
- Verify files deleted: check data/uploads/ directory
Test error cases:
- Try camera on desktop without webcam - should show friendly error
- Try uploading non-image file - should show error
</how-to-verify>
<resume-signal>Type "approved" if all tests pass, or describe issues found</resume-signal>
</task>
</tasks>
<verification>
1. `npm run check` passes
2. `npm run dev` - server starts
3. All components exist:
- src/lib/components/ImageGallery.svelte
- src/lib/components/ImageLightbox.svelte
4. EntryCard imports and uses image components
5. Human verification of complete image flow
</verification>
<success_criteria>
- Entry cards show image indicator when images attached
- Expanded entry shows horizontal scrolling gallery
- Clicking image opens fullscreen lightbox
- Edit mode allows deleting images
- Upload and camera available for adding images to existing entries
- Human verified: complete image flow works on desktop and mobile
</success_criteria>
<output>
After completion, create `.planning/phases/03-images/03-04-SUMMARY.md`
</output>