Compare commits

..

1 commit

Author SHA1 Message Date
e68b8a3a09 feat: integrate sd-card for interactive spec generation
## Changes

- Replaced forgejo-workflow with sd-card flake input
- Added `nix run .#dev` command - starts Vite with annotation proxy
- Updated dev shell with sd-card tools (tea, playwright, nodejs)

## Usage

**Start dev server with annotation widget:**
```bash
FORGEJO_TOKEN="your-token" nix run .#dev
```

Opens SvelteKit on random ports with ✦ widget injected.

**Interactive annotation with Claudezilla:**
Ask Claude to open the app in Firefox and interact naturally:
- "Open http://localhost:5173"
- "Click the add item button"
- "This should show a form"

Claude generates Gherkin scenarios from the interactive session.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 00:04:36 +01:00
14 changed files with 54 additions and 6703 deletions

View file

@ -1 +0,0 @@
{"id":"req_20260226_234700_photo","timestamp":"2026-02-26T23:47:00Z","prompt":"Can we do photo capture with indexeddb and pwa?","session":"feat-photo-capture","branch":"feat/20260226-photo-capture","pr_number":1,"pr_url":"https://git.toph.so/toph/solidhaus/pulls/1","status":"open"}

View file

@ -1,57 +0,0 @@
name: Build and Push OCI Image
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: nix
steps:
- name: Checkout code
uses: https://code.forgejo.org/actions/checkout@v4
- name: Build OCI image
run: |
# Try with cache first, fall back to building from source
nix build .#kammer-image \
--print-build-logs \
--show-trace || {
echo "Build failed, retrying with --no-substitute to build from source..."
nix build .#kammer-image \
--print-build-logs \
--show-trace \
--option substitute false
}
- name: Push to registry
if: github.ref == 'refs/heads/main'
run: |
# Ensure temp directory exists for skopeo
mkdir -p /var/tmp /tmp
image=$(nix build --no-link --print-out-paths .#kammer-image)
skopeo copy \
--dest-tls-verify=false \
--tmpdir /tmp \
"docker-archive:$image" \
"docker://registry.toph.so/kammer:latest"
# Also tag with commit SHA
skopeo copy \
--dest-tls-verify=false \
--tmpdir /tmp \
"docker-archive:$image" \
"docker://registry.toph.so/kammer:${GITHUB_SHA:0:7}"
- name: Build summary
if: github.ref == 'refs/heads/main'
run: |
echo "### ✅ Image Built and Pushed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: registry.toph.so/kammer:latest" >> $GITHUB_STEP_SUMMARY
echo "- **Tag**: ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit**: ${GITHUB_SHA}" >> $GITHUB_STEP_SUMMARY

View file

@ -1,120 +0,0 @@
# Nix Build System
SolidHaus uses Nix flakes for reproducible builds and automated OCI image creation.
## Quick Start
```bash
# Build the SvelteKit app
nix build .#solidhaus
# Build the OCI image
nix build .#solidhaus-image
# Push to registry (requires authentication)
nix run .#push-solidhaus-image
# Enter dev shell
nix develop
```
## Packages
### `solidhaus`
Builds the SvelteKit application using `buildNpmPackage`.
- **Output**: Static SPA in `/nix/store/.../`
- **Build command**: `npm run build`
- **Dependencies**: Locked with `npmDepsHash`
### `solidhaus-image`
OCI image with nginx serving the built app.
- **Image name**: `registry.toph.so/solidhaus`
- **Tag**: `latest`
- **Web server**: nginx
- **Port**: 80
**Features**:
- SPA fallback routing (`try_files $uri /index.html`)
- Gzip compression for text assets
- Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection)
- 1-year cache for static assets (`.js`, `.css`, `.woff2`, etc.)
## CI/CD
Forgejo Actions workflow (`.forgejo/workflows/build-image.yml`) automatically:
1. **On every push/PR**: Build OCI image to verify it works
2. **On main branch**: Push image to `registry.toph.so/solidhaus`
- Tagged as `:latest`
- Tagged with `:${commit-sha}` for rollback capability
**Requirements**:
- Forgejo runner with `nix` label
- Uses `registry.toph.so/nix-runner:latest` (see `/srv/infra/jobs/forgejo-runner/image.nix`)
## Updating Dependencies
When `package.json` dependencies change:
1. Build will fail with hash mismatch
2. Copy the "got:" hash from error message
3. Update `npmDepsHash` in `flake.nix`
4. Rebuild
Example:
```
error: hash mismatch in fixed-output derivation
specified: sha256-AAAAA...
got: sha256-BBBBB...
```
Update `flake.nix`:
```nix
npmDepsHash = "sha256-BBBBB...";
```
## Local Testing
Test the OCI image locally:
```bash
# Build image
nix build .#solidhaus-image
# Load into Docker/Podman
docker load < result
# Run
docker run -p 8080:80 registry.toph.so/solidhaus:latest
# Visit http://localhost:8080
```
## Integration with /srv/infra
The Nomad job definition in `/srv/infra/jobs/solidhaus/default.nix` can be updated to use the OCI image:
```nix
task.web = {
driver = "docker";
config = {
image = "registry.toph.so/solidhaus:latest";
ports = ["http"];
};
# ... rest of config
};
```
This replaces the current two-task setup (build + web) with a single pre-built image.
## Why Nix?
- **Reproducible builds**: Same inputs = same outputs, always
- **Hermetic builds**: No reliance on system Node.js version
- **Cacheable**: Nix store enables efficient caching
- **Declarative**: Build configuration is code
- **Flake-based**: Pin exact dependency versions (flake.lock)

View file

@ -1,156 +0,0 @@
Feature: Item UID Generation - Short Collision-Resistant IDs
As a user creating a new item
I want to see a pre-generated short UID (7 characters)
So that IDs remain compact, memorable, and shareable while avoiding collisions
Background:
Given I am on the "New Item" form
Rule: UIDs are pre-generated on form load
Scenario: UID is pre-generated when form loads
When the form loads
Then I should see a "UID" field
And the UID field should contain a 7-character ID
And the UID should use only characters from "23456789abcdefghjkmnpqrstuvwxyz"
And the UID should be displayed prominently near the top of the form
Scenario: UID field is read-only with regenerate option
When the form loads
Then the UID field should be read-only or disabled for manual editing
And I should see a "Regenerate" button/icon next to the UID field
And the regenerate control should be clearly clickable
Rule: Users can regenerate UIDs before submission
Scenario: User regenerates UID once
Given the form has loaded with UID "qe3saa3"
When I click the "Regenerate" button
Then a new 7-character UID should be generated
And the new UID should be different from "qe3saa3"
And the new UID should be displayed in the UID field immediately
Scenario: User regenerates UID multiple times
Given the form has loaded with UID "qe3saa3"
When I click the "Regenerate" button
And the new UID is "n4p7m2k"
And I click the "Regenerate" button again
Then another new 7-character UID should be generated
And each generated UID should be unique
And the latest UID should replace the previous one
Scenario: Regenerated UID is used on submission
Given the form has loaded with UID "qe3saa3"
And I fill in "Name" with "Cable Drum"
When I click the "Regenerate" button
And the new UID is "n4p7m2k"
And I submit the form
Then the item should be created with shortId "n4p7m2k"
And the barcode URI should be "https://haus.toph.so/n4p7m2k"
And the original UID "qe3saa3" should NOT be used
Rule: UID format and collision resistance
Scenario: UID uses safe alphabet
When a UID is generated
Then it should use alphabet "23456789abcdefghjkmnpqrstuvwxyz"
And it should exclude confusing characters (0, 1, i, l, o)
And it should be exactly 7 characters long
And all characters should be lowercase
Scenario: Collision probability is negligible
Given the alphabet has 29 characters (excluding 0, 1, i, l, o)
And the UID length is 7 characters
Then the total possible IDs should be 29^7 = 17,249,876,309
And collision probability for 1,000 items should be approximately 0.000003%
And collision probability for 10,000 items should be approximately 0.0003%
And collision probability for 100,000 items should be approximately 0.03%
Scenario Outline: UID examples are valid
When a UID "<uid>" is generated
Then it should match the pattern "^[23456789a-hjkmnp-z]{7}$"
And it should NOT contain any of "0 1 i l o"
Examples:
| uid |
| qe3saa3 |
| n4p7m2k |
| 2jk8xab |
| zzz9999 |
| 2222222 |
Rule: UID field UI/UX specifications
Scenario: UID field placement
When the form loads
Then the UID field should be positioned near the top
And it should appear before or after the "Name" field
And it should be clearly labeled "UID" or "Item ID"
Scenario: UID field styling
When the form loads
Then the UID field should use monospace font
And it should have larger text size for readability
And it should have letter-spacing for clarity
And the field should visually indicate it's read-only
Scenario: Regenerate button styling
When the form loads
Then the "Regenerate" button should be compact
And it should use an icon (🔄 or ) or short text
And it should be positioned inline with the UID field (right side)
And it should have a clear hover state
And it should have appropriate spacing from the UID field
Scenario: Regenerate button interaction
When I hover over the "Regenerate" button
Then it should show a visual hover state
And optionally show a tooltip "Generate new ID"
When I click the "Regenerate" button
Then the button should show brief loading/animation feedback
And the new UID should appear immediately
Rule: UID persistence and submission
Scenario: UID is submitted with item
Given the form has pre-generated UID "qe3saa3"
And I fill in "Name" with "Laptop"
And I fill in "Category" with "Electronics"
When I submit the form
Then the item should be created with shortId "qe3saa3"
And the database should store shortId as primary key
And the item detail page should display "qe3saa3"
And the barcode should encode "https://haus.toph.so/qe3saa3"
Scenario: Creation timestamp stored separately
Given the form has pre-generated UID "qe3saa3"
When I submit the form at 2024-02-27T14:30:00Z
Then the item should have shortId "qe3saa3"
And the item should have createdAt "2024-02-27T14:30:00Z"
And the item should have updatedAt "2024-02-27T14:30:00Z"
And timestamps should be independent of the UID
Rule: Edge cases and validation
Scenario: Duplicate UID is detected (rare collision)
Given an item exists with UID "qe3saa3"
And the form generates the same UID "qe3saa3" by chance
When I submit the form
Then the system should detect the collision
And the system should automatically generate a new UID
And the item should be created successfully with a different UID
And the user should be notified "ID regenerated due to conflict"
Scenario: UID persists during form edits
Given the form has loaded with UID "qe3saa3"
When I fill in "Name" with "Cable"
And I change "Name" to "Cable Drum"
And I fill in other fields
Then the UID should remain "qe3saa3"
And the UID should NOT auto-regenerate on field changes
Scenario: UID regenerates on explicit action only
Given the form has loaded with UID "qe3saa3"
When I interact with any form field
Then the UID should remain unchanged
And the UID should only change when "Regenerate" is clicked

70
flake.lock generated
View file

@ -1,23 +1,5 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -36,6 +18,24 @@
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771848320,
@ -52,21 +52,6 @@
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1771848320,
@ -85,14 +70,14 @@
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"sd-card": "sd-card"
}
},
"sd-card": {
"inputs": {
"flake-utils": "flake-utils",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
@ -123,6 +108,21 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

174
flake.nix
View file

@ -1,180 +1,34 @@
{
description = "Kammer Local-first household inventory app";
description = "solidhaus dev shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
flake-utils.url = "github:numtide/flake-utils";
sd-card.url = "git+ssh://git@git.toph.so/toph/sd-card";
};
outputs = inputs @ {
flake-parts,
nixpkgs,
sd-card,
...
}:
flake-parts.lib.mkFlake {inherit inputs;} {
systems = ["x86_64-linux" "aarch64-linux"];
perSystem = {
config,
pkgs,
system,
...
}: let
nodejs = pkgs.nodejs_22;
buildNpmPackage = pkgs.buildNpmPackage.override {
nodejs = nodejs;
};
in {
outputs = { self, nixpkgs, flake-utils, sd-card }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages = {
# Build the SvelteKit app
kammer = buildNpmPackage {
pname = "kammer";
version = "0.0.1";
src = ./.;
npmDepsHash = "sha256-d7k2YpmFt/Ba0j0SrhgoHQdhYjxHO46BliOzyecZgbY=";
buildPhase = ''
npm run build
'';
installPhase = ''
mkdir -p $out
cp -r build/* $out/
'';
meta = {
description = "Local-first household inventory app with barcode scanning";
homepage = "https://git.toph.so/toph/kammer";
};
};
# OCI image with nginx serving the built app
kammer-image = pkgs.dockerTools.buildLayeredImage {
name = "registry.toph.so/kammer";
tag = "latest";
contents = with pkgs; [
fakeNss
nginx
];
config = {
Cmd = [
"${pkgs.nginx}/bin/nginx"
"-c"
"/etc/nginx/nginx.conf"
"-g"
"daemon off;"
];
ExposedPorts = {
"80/tcp" = {};
};
WorkingDir = "/usr/share/nginx/html";
};
extraCommands = ''
# Create directory structure
mkdir -p var/log/nginx
mkdir -p var/cache/nginx
mkdir -p tmp
mkdir -p etc/nginx
# Copy built app
mkdir -p usr/share/nginx/html
cp -r ${config.packages.kammer}/* usr/share/nginx/html/
# Create nginx config
cat > etc/nginx/nginx.conf <<'EOF'
user nobody nobody;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include ${pkgs.nginx}/conf/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
EOF
'';
};
# nix run .#dev — start SvelteKit dev server with annotation proxy
dev = sd-card.lib.${system}.mkDevApp {
inherit pkgs;
devCmd = "${nodejs}/bin/npm run dev";
devCmd = "npm run dev";
devPort = 5173; # Vite default port
};
default = config.packages.kammer;
};
apps = {
# Push image to registry
push-kammer-image = {
type = "app";
program = pkgs.lib.getExe (pkgs.writeShellApplication {
name = "push-kammer-image";
runtimeInputs = [pkgs.skopeo];
text = ''
image=$(nix build --no-link --print-out-paths .#kammer-image)
skopeo copy \
--insecure-policy \
"docker-archive:$image" \
"docker://registry.toph.so/kammer:latest"
'';
});
};
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
packages = sd-card.packages.${system}.tools # tea, jq, playwright, nodejs
++ (with pkgs; [
nodejs
nodePackages.npm
];
inputsFrom = [sd-card.packages.${system}.tools];
};
};
]);
};
}
);
}

5500
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,6 @@
"@automerge/automerge-repo-network-websocket": "^2.5.3",
"@automerge/automerge-repo-storage-indexeddb": "^2.5.3",
"@capacitor-mlkit/barcode-scanning": "^8.0.1",
"@capacitor/camera": "^8.0.1",
"@capacitor/cli": "^8.1.0",
"@capacitor/core": "^8.1.0",
"@inrupt/solid-client": "^3.0.0",

1
result
View file

@ -1 +0,0 @@
/nix/store/3dna41ydmr1ia5fiicgzf19p1z68l10v-solidhaus.tar.gz

View file

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

View file

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

View file

@ -1,156 +0,0 @@
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,/);
});
});
});

View file

@ -1,119 +0,0 @@
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);
});
}

View file

@ -6,8 +6,6 @@
import CustodyBadge from '$lib/components/CustodyBadge.svelte';
import LocationPicker from '$lib/components/LocationPicker.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 { formatDistanceToNow } from 'date-fns';
import type { Item, CheckOutReason } from '$lib/types';
@ -27,8 +25,6 @@
let checkOutNote = $state('');
let checkInLocation = $state('');
let confirmDelete = $state(false);
let showPhotoCapture = $state(false);
let photoGallery: PhotoGallery | null = $state(null);
async function handleUpdate(data: Partial<Item>) {
if (!item) return;
@ -59,11 +55,6 @@
await inventory.removeItem(item.shortId);
goto('/items');
}
function handlePhotoAdded() {
showPhotoCapture = false;
photoGallery?.refresh();
}
</script>
{#if !item}
@ -163,27 +154,6 @@
</div>
{/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 -->
{#if item.custodyState === 'checked-in'}
{#if showCheckOut}