feat: PWA photo capture with IndexedDB storage #1
7 changed files with 6168 additions and 0 deletions
5500
package-lock.json
generated
Normal file
5500
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,7 @@
|
||||||
"@automerge/automerge-repo-network-websocket": "^2.5.3",
|
"@automerge/automerge-repo-network-websocket": "^2.5.3",
|
||||||
"@automerge/automerge-repo-storage-indexeddb": "^2.5.3",
|
"@automerge/automerge-repo-storage-indexeddb": "^2.5.3",
|
||||||
"@capacitor-mlkit/barcode-scanning": "^8.0.1",
|
"@capacitor-mlkit/barcode-scanning": "^8.0.1",
|
||||||
|
"@capacitor/camera": "^8.0.1",
|
||||||
"@capacitor/cli": "^8.1.0",
|
"@capacitor/cli": "^8.1.0",
|
||||||
"@capacitor/core": "^8.1.0",
|
"@capacitor/core": "^8.1.0",
|
||||||
"@inrupt/solid-client": "^3.0.0",
|
"@inrupt/solid-client": "^3.0.0",
|
||||||
|
|
|
||||||
208
src/lib/components/PhotoCapture.svelte
Normal file
208
src/lib/components/PhotoCapture.svelte
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* PhotoCapture — Camera capture component (PWA-friendly)
|
||||||
|
*
|
||||||
|
* Uses standard Web APIs:
|
||||||
|
* - getUserMedia for camera access
|
||||||
|
* - File input as fallback
|
||||||
|
* - Canvas for image processing
|
||||||
|
*/
|
||||||
|
import { createPhoto } from '$lib/data/photos';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
itemId: string;
|
||||||
|
onPhotoAdded?: (photoId: string) => void;
|
||||||
|
mode?: 'camera' | 'file' | 'both';
|
||||||
|
};
|
||||||
|
|
||||||
|
let { itemId, onPhotoAdded, mode = 'both' }: Props = $props();
|
||||||
|
|
||||||
|
let capturing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let videoElement: HTMLVideoElement | null = $state(null);
|
||||||
|
let stream: MediaStream | null = $state(null);
|
||||||
|
let fileInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
capturing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request camera with back camera preferred (mobile)
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment', // Back camera on mobile
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
await videoElement.play();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Camera access error:', err);
|
||||||
|
error = err instanceof Error ? err.message : 'Could not access camera';
|
||||||
|
capturing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = null;
|
||||||
|
}
|
||||||
|
capturing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capturePhoto() {
|
||||||
|
if (!videoElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create canvas from video frame
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = videoElement.videoWidth;
|
||||||
|
canvas.height = videoElement.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Could not get canvas context');
|
||||||
|
|
||||||
|
ctx.drawImage(videoElement, 0, 0);
|
||||||
|
|
||||||
|
// Convert to blob
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => {
|
||||||
|
if (b) resolve(b);
|
||||||
|
else reject(new Error('Could not create blob'));
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.9
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save to IndexedDB
|
||||||
|
const photo = await createPhoto(itemId, blob);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
stopCamera();
|
||||||
|
|
||||||
|
// Notify parent
|
||||||
|
onPhotoAdded?.(photo.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Capture error:', err);
|
||||||
|
error = err instanceof Error ? err.message : 'Could not capture photo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save to IndexedDB
|
||||||
|
const photo = await createPhoto(itemId, file);
|
||||||
|
|
||||||
|
// Notify parent
|
||||||
|
onPhotoAdded?.(photo.id);
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload error:', err);
|
||||||
|
error = err instanceof Error ? err.message : 'Could not upload photo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
stopCamera();
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCamera();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if capturing}
|
||||||
|
<!-- Camera view -->
|
||||||
|
<div class="fixed inset-0 z-50 bg-black flex flex-col">
|
||||||
|
<!-- Video preview -->
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="bg-slate-900 p-4 flex gap-4 justify-center items-center">
|
||||||
|
<button
|
||||||
|
onclick={handleCancel}
|
||||||
|
class="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={capturePhoto}
|
||||||
|
class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white text-2xl hover:bg-blue-600 transition shadow-lg"
|
||||||
|
aria-label="Capture photo"
|
||||||
|
>
|
||||||
|
📷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-500/20 text-red-400 p-3 m-4 rounded-lg border border-red-500/50">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Trigger buttons -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if mode === 'camera' || mode === 'both'}
|
||||||
|
<button
|
||||||
|
onclick={startCamera}
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="text-xl">📷</span>
|
||||||
|
<span>Take Photo</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'file' || mode === 'both'}
|
||||||
|
<label
|
||||||
|
class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition cursor-pointer flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="text-xl">🖼️</span>
|
||||||
|
<span>Upload</span>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-500/20 text-red-400 p-3 mt-2 rounded-lg border border-red-500/50 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
154
src/lib/components/PhotoGallery.svelte
Normal file
154
src/lib/components/PhotoGallery.svelte
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* PhotoGallery — Display and manage item photos
|
||||||
|
*/
|
||||||
|
import { getPhotosByItemId, deletePhoto, blobToDataURL } from '$lib/data/photos';
|
||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
itemId: string;
|
||||||
|
editable?: boolean;
|
||||||
|
onPhotosChanged?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { itemId, editable = false, onPhotosChanged }: Props = $props();
|
||||||
|
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let photoUrls = $state<Map<string, string>>(new Map());
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedPhotoId = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function loadPhotos() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
photos = await getPhotosByItemId(itemId);
|
||||||
|
|
||||||
|
// Convert blobs to data URLs for display
|
||||||
|
const urls = new Map<string, string>();
|
||||||
|
for (const photo of photos) {
|
||||||
|
const url = await blobToDataURL(photo.thumbnail || photo.blob);
|
||||||
|
urls.set(photo.id, url);
|
||||||
|
}
|
||||||
|
photoUrls = urls;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load photos:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(photoId: string) {
|
||||||
|
if (!confirm('Delete this photo?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePhoto(photoId);
|
||||||
|
photos = photos.filter((p) => p.id !== photoId);
|
||||||
|
photoUrls.delete(photoId);
|
||||||
|
photoUrls = new Map(photoUrls);
|
||||||
|
onPhotosChanged?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete photo:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhotoClick(photoId: string) {
|
||||||
|
selectedPhotoId = photoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
selectedPhotoId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load photos on mount and when itemId changes
|
||||||
|
$effect(() => {
|
||||||
|
loadPhotos();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public method to refresh photos (called by parent after adding new photo)
|
||||||
|
export function refresh() {
|
||||||
|
loadPhotos();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center p-8 text-slate-400">
|
||||||
|
<div class="animate-pulse">Loading photos...</div>
|
||||||
|
</div>
|
||||||
|
{:else if photos.length === 0}
|
||||||
|
<div class="text-center p-8 text-slate-400 text-sm">
|
||||||
|
<div class="text-4xl mb-2">📷</div>
|
||||||
|
<p>No photos yet</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Photo grid -->
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each photos as photo (photo.id)}
|
||||||
|
{@const url = photoUrls.get(photo.id)}
|
||||||
|
<div class="relative aspect-square bg-slate-800 rounded-lg overflow-hidden group">
|
||||||
|
{#if url}
|
||||||
|
<button
|
||||||
|
onclick={() => handlePhotoClick(photo.id)}
|
||||||
|
class="w-full h-full block"
|
||||||
|
aria-label="View photo"
|
||||||
|
>
|
||||||
|
<img src={url} alt="Item photo" class="w-full h-full object-cover" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if editable}
|
||||||
|
<button
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(photo.id);
|
||||||
|
}}
|
||||||
|
class="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full opacity-0 group-hover:opacity-100 transition flex items-center justify-center shadow-lg"
|
||||||
|
aria-label="Delete photo"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Fullscreen modal -->
|
||||||
|
{#if selectedPhotoId}
|
||||||
|
{@const selectedPhoto = photos.find((p) => p.id === selectedPhotoId)}
|
||||||
|
{#if selectedPhoto}
|
||||||
|
{@const fullUrl = photoUrls.get(selectedPhotoId)}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
||||||
|
onclick={closeModal}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Close photo"
|
||||||
|
>
|
||||||
|
<div class="relative max-w-4xl max-h-full" onclick={(e) => e.stopPropagation()}>
|
||||||
|
{#if fullUrl}
|
||||||
|
<img src={fullUrl} alt="Item photo" class="max-w-full max-h-screen rounded-lg" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={closeModal}
|
||||||
|
class="absolute top-4 right-4 bg-slate-900/80 text-white w-10 h-10 rounded-full flex items-center justify-center hover:bg-slate-800 transition"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if editable}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
handleDelete(selectedPhotoId);
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
class="absolute bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600 transition"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
156
src/lib/data/photos.test.ts
Normal file
156
src/lib/data/photos.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createPhoto,
|
||||||
|
getPhotosByItemId,
|
||||||
|
getPhotoById,
|
||||||
|
deletePhoto,
|
||||||
|
deletePhotosByItemId,
|
||||||
|
blobToDataURL,
|
||||||
|
} from './photos';
|
||||||
|
import { getDB, resetDBPromise } from './db';
|
||||||
|
|
||||||
|
// Mock canvas for thumbnail generation
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock canvas context
|
||||||
|
const mockContext = {
|
||||||
|
drawImage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock HTMLCanvasElement
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
HTMLCanvasElement.prototype.getContext = vi.fn(() => mockContext) as any;
|
||||||
|
HTMLCanvasElement.prototype.toBlob = vi.fn(function (callback) {
|
||||||
|
callback?.(new Blob(['thumbnail'], { type: 'image/jpeg' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock HTMLImageElement
|
||||||
|
global.Image = class {
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
src = '';
|
||||||
|
width = 1920;
|
||||||
|
height = 1080;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.onload?.();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} as unknown as typeof Image;
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL
|
||||||
|
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
|
||||||
|
global.URL.revokeObjectURL = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up database between tests
|
||||||
|
const db = await getDB();
|
||||||
|
await db.clear('photos');
|
||||||
|
await resetDBPromise();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('photos', () => {
|
||||||
|
describe('createPhoto', () => {
|
||||||
|
it('creates a photo with thumbnail', async () => {
|
||||||
|
const blob = new Blob(['test image'], { type: 'image/jpeg' });
|
||||||
|
const photo = await createPhoto('item_abc', blob);
|
||||||
|
|
||||||
|
expect(photo.id).toMatch(/^photo_/);
|
||||||
|
expect(photo.itemId).toBe('item_abc');
|
||||||
|
expect(photo.blob).toBe(blob);
|
||||||
|
expect(photo.thumbnail).toBeInstanceOf(Blob);
|
||||||
|
expect(photo.createdAt).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify it was saved to DB
|
||||||
|
const db = await getDB();
|
||||||
|
const saved = await db.get('photos', photo.id);
|
||||||
|
expect(saved?.id).toBe(photo.id);
|
||||||
|
expect(saved?.itemId).toBe(photo.itemId);
|
||||||
|
expect(saved?.createdAt).toBe(photo.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique IDs', async () => {
|
||||||
|
const blob = new Blob(['test'], { type: 'image/jpeg' });
|
||||||
|
const photo1 = await createPhoto('item_abc', blob);
|
||||||
|
const photo2 = await createPhoto('item_abc', blob);
|
||||||
|
|
||||||
|
expect(photo1.id).not.toBe(photo2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPhotosByItemId', () => {
|
||||||
|
it('returns all photos for an item', async () => {
|
||||||
|
const blob = new Blob(['test'], { type: 'image/jpeg' });
|
||||||
|
const photo1 = await createPhoto('item_abc', blob);
|
||||||
|
const photo2 = await createPhoto('item_abc', blob);
|
||||||
|
await createPhoto('item_xyz', blob); // different item
|
||||||
|
|
||||||
|
const photos = await getPhotosByItemId('item_abc');
|
||||||
|
|
||||||
|
expect(photos).toHaveLength(2);
|
||||||
|
expect(photos.map((p) => p.id).sort()).toEqual([photo1.id, photo2.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if no photos', async () => {
|
||||||
|
const photos = await getPhotosByItemId('nonexistent');
|
||||||
|
expect(photos).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPhotoById', () => {
|
||||||
|
it('returns a photo by ID', async () => {
|
||||||
|
const blob = new Blob(['test'], { type: 'image/jpeg' });
|
||||||
|
const photo = await createPhoto('item_abc', blob);
|
||||||
|
|
||||||
|
const retrieved = await getPhotoById(photo.id);
|
||||||
|
expect(retrieved?.id).toBe(photo.id);
|
||||||
|
expect(retrieved?.itemId).toBe(photo.itemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if not found', async () => {
|
||||||
|
const photo = await getPhotoById('nonexistent');
|
||||||
|
expect(photo).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deletePhoto', () => {
|
||||||
|
it('deletes a photo', async () => {
|
||||||
|
const blob = new Blob(['test'], { type: 'image/jpeg' });
|
||||||
|
const photo = await createPhoto('item_abc', blob);
|
||||||
|
|
||||||
|
await deletePhoto(photo.id);
|
||||||
|
|
||||||
|
const retrieved = await getPhotoById(photo.id);
|
||||||
|
expect(retrieved).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deletePhotosByItemId', () => {
|
||||||
|
it('deletes all photos for an item', async () => {
|
||||||
|
const blob = new Blob(['test'], { type: 'image/jpeg' });
|
||||||
|
await createPhoto('item_abc', blob);
|
||||||
|
await createPhoto('item_abc', blob);
|
||||||
|
const photo3 = await createPhoto('item_xyz', blob); // different item
|
||||||
|
|
||||||
|
await deletePhotosByItemId('item_abc');
|
||||||
|
|
||||||
|
const photos = await getPhotosByItemId('item_abc');
|
||||||
|
expect(photos).toEqual([]);
|
||||||
|
|
||||||
|
// Other item's photos should remain
|
||||||
|
const otherPhotos = await getPhotosByItemId('item_xyz');
|
||||||
|
expect(otherPhotos).toHaveLength(1);
|
||||||
|
expect(otherPhotos[0].id).toBe(photo3.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blobToDataURL', () => {
|
||||||
|
it('converts a blob to data URL', async () => {
|
||||||
|
const blob = new Blob(['test'], { type: 'image/jpeg' });
|
||||||
|
const dataUrl = await blobToDataURL(blob);
|
||||||
|
|
||||||
|
expect(dataUrl).toMatch(/^data:image\/jpeg;base64,/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
src/lib/data/photos.ts
Normal file
119
src/lib/data/photos.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
import CustodyBadge from '$lib/components/CustodyBadge.svelte';
|
import CustodyBadge from '$lib/components/CustodyBadge.svelte';
|
||||||
import LocationPicker from '$lib/components/LocationPicker.svelte';
|
import LocationPicker from '$lib/components/LocationPicker.svelte';
|
||||||
import ItemForm from '$lib/components/ItemForm.svelte';
|
import ItemForm from '$lib/components/ItemForm.svelte';
|
||||||
|
import PhotoGallery from '$lib/components/PhotoGallery.svelte';
|
||||||
|
import PhotoCapture from '$lib/components/PhotoCapture.svelte';
|
||||||
import { getConfidence } from '$lib/utils/confidence';
|
import { getConfidence } from '$lib/utils/confidence';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import type { Item, CheckOutReason } from '$lib/types';
|
import type { Item, CheckOutReason } from '$lib/types';
|
||||||
|
|
@ -25,6 +27,8 @@
|
||||||
let checkOutNote = $state('');
|
let checkOutNote = $state('');
|
||||||
let checkInLocation = $state('');
|
let checkInLocation = $state('');
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
|
let showPhotoCapture = $state(false);
|
||||||
|
let photoGallery: PhotoGallery | null = $state(null);
|
||||||
|
|
||||||
async function handleUpdate(data: Partial<Item>) {
|
async function handleUpdate(data: Partial<Item>) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
@ -55,6 +59,11 @@
|
||||||
await inventory.removeItem(item.shortId);
|
await inventory.removeItem(item.shortId);
|
||||||
goto('/items');
|
goto('/items');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePhotoAdded() {
|
||||||
|
showPhotoCapture = false;
|
||||||
|
photoGallery?.refresh();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !item}
|
{#if !item}
|
||||||
|
|
@ -154,6 +163,27 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<div class="bg-slate-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="font-medium text-white">Photos</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => (showPhotoCapture = !showPhotoCapture)}
|
||||||
|
class="text-blue-400 text-sm hover:text-blue-300"
|
||||||
|
>
|
||||||
|
{showPhotoCapture ? 'Cancel' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showPhotoCapture}
|
||||||
|
<div class="mb-4">
|
||||||
|
<PhotoCapture itemId={item.shortId} onPhotoAdded={handlePhotoAdded} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<PhotoGallery bind:this={photoGallery} itemId={item.shortId} editable={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Check In/Out -->
|
<!-- Check In/Out -->
|
||||||
{#if item.custodyState === 'checked-in'}
|
{#if item.custodyState === 'checked-in'}
|
||||||
{#if showCheckOut}
|
{#if showCheckOut}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue