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:
@@ -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 | - |
|
||||
|
||||
224
.planning/phases/03-images/03-01-PLAN.md
Normal file
224
.planning/phases/03-images/03-01-PLAN.md
Normal 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>
|
||||
284
.planning/phases/03-images/03-02-PLAN.md
Normal file
284
.planning/phases/03-images/03-02-PLAN.md
Normal 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>
|
||||
263
.planning/phases/03-images/03-03-PLAN.md
Normal file
263
.planning/phases/03-images/03-03-PLAN.md
Normal 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>
|
||||
414
.planning/phases/03-images/03-04-PLAN.md
Normal file
414
.planning/phases/03-images/03-04-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user