kammer/src/lib/data/photos.ts
Christopher Mühl 91c7476d37
feat: PWA photo capture with IndexedDB storage
Add full photo capture and management functionality using standard Web APIs:

- Photo capture via getUserMedia (camera) or file upload
- Automatic thumbnail generation (max 200px width)
- IndexedDB storage for photos with Blob support
- PhotoCapture component with camera preview and capture controls
- PhotoGallery component with grid view and fullscreen modal
- Integration into item detail page
- 9 new unit tests (all passing)

PWA-friendly implementation:
- No native dependencies required
- Works in mobile browsers
- Falls back to file upload if camera unavailable
- Stores photos locally in IndexedDB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 23:44:46 +01:00

119 lines
2.7 KiB
TypeScript

import { getDB } from './db';
import type { Photo } from '$lib/types';
import { nanoid } from 'nanoid';
/**
* Get all photos for a specific item
*/
export async function getPhotosByItemId(itemId: string): Promise<Photo[]> {
const db = await getDB();
return db.getAllFromIndex('photos', 'by-item', itemId);
}
/**
* Get a specific photo by ID
*/
export async function getPhotoById(id: string): Promise<Photo | undefined> {
const db = await getDB();
return db.get('photos', id);
}
/**
* Create a new photo
* Automatically generates thumbnail (max 200px width)
*/
export async function createPhoto(itemId: string, blob: Blob): Promise<Photo> {
const thumbnail = await generateThumbnail(blob, 200);
const photo: Photo = {
id: `photo_${nanoid(10)}`,
itemId,
blob,
thumbnail,
createdAt: new Date().toISOString(),
};
const db = await getDB();
await db.put('photos', photo);
return photo;
}
/**
* Delete a photo
*/
export async function deletePhoto(id: string): Promise<void> {
const db = await getDB();
await db.delete('photos', id);
}
/**
* Delete all photos for an item
*/
export async function deletePhotosByItemId(itemId: string): Promise<void> {
const photos = await getPhotosByItemId(itemId);
const db = await getDB();
await Promise.all(photos.map((photo) => db.delete('photos', photo.id)));
}
/**
* Generate a thumbnail from a blob
*/
async function generateThumbnail(blob: Blob, maxWidth: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
// Calculate dimensions
const scale = maxWidth / img.width;
const width = maxWidth;
const height = img.height * scale;
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
// Draw scaled image
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
// Convert to blob
canvas.toBlob(
(thumbnailBlob) => {
URL.revokeObjectURL(url);
if (thumbnailBlob) {
resolve(thumbnailBlob);
} else {
reject(new Error('Could not create thumbnail'));
}
},
'image/jpeg',
0.8
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Could not load image'));
};
img.src = url;
});
}
/**
* Convert a blob to a data URL for display
*/
export function blobToDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}