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
|
3. User can view attached images inline with entry
|
||||||
4. User can remove image attachments from an entry
|
4. User can remove image attachments from an entry
|
||||||
5. Images are stored on filesystem (not in database)
|
5. Images are stored on filesystem (not in database)
|
||||||
**Plans**: TBD
|
**Plans**: 4 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 03-01: TBD
|
- [ ] 03-01-PLAN.md — Database schema, file storage, thumbnail generation, and API endpoints
|
||||||
- [ ] 03-02: TBD
|
- [ ] 03-02-PLAN.md — File upload form action and ImageUpload component with drag-drop
|
||||||
- [ ] 03-03: TBD
|
- [ ] 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
|
### Phase 4: Tags & Organization
|
||||||
**Goal**: Users can organize entries with tags and quick access features
|
**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 |
|
| 1. Foundation | 2/2 | Complete ✓ | 2026-01-29 |
|
||||||
| 2. Core CRUD | 4/4 | 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 | - |
|
| 4. Tags & Organization | 0/3 | Not started | - |
|
||||||
| 5. Search | 0/3 | Not started | - |
|
| 5. Search | 0/3 | Not started | - |
|
||||||
| 6. Deployment | 0/2 | 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