Compare commits
No commits in common. "2210c5fcf79020b1913010d0c0499f50a343ef48" and "16e9c891a8cf114a2d1f9a67c245650ce326769c" have entirely different histories.
2210c5fcf7
...
16e9c891a8
12 changed files with 56 additions and 6536 deletions
|
|
@ -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"}
|
|
||||||
|
|
@ -1,45 +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: |
|
|
||||||
nix build .#solidhaus-image \
|
|
||||||
--print-build-logs \
|
|
||||||
--show-trace
|
|
||||||
|
|
||||||
- name: Push to registry
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
run: |
|
|
||||||
image=$(nix build --no-link --print-out-paths .#solidhaus-image)
|
|
||||||
skopeo copy \
|
|
||||||
--dest-tls-verify=false \
|
|
||||||
"docker-archive:$image" \
|
|
||||||
"docker://registry.toph.so/solidhaus:latest"
|
|
||||||
|
|
||||||
# Also tag with commit SHA
|
|
||||||
skopeo copy \
|
|
||||||
--dest-tls-verify=false \
|
|
||||||
"docker-archive:$image" \
|
|
||||||
"docker://registry.toph.so/solidhaus:${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/solidhaus:latest" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Tag**: ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Commit**: ${GITHUB_SHA}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
120
NIX_BUILD.md
120
NIX_BUILD.md
|
|
@ -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)
|
|
||||||
74
flake.lock
generated
74
flake.lock
generated
|
|
@ -1,23 +1,5 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
|
|
@ -36,34 +18,37 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771903837,
|
"lastModified": 1731533236,
|
||||||
"narHash": "sha256-sdaqdnsQCv3iifzxwB22tUwN/fSHoN7j2myFW5EIkGk=",
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
"owner": "NixOS",
|
"owner": "numtide",
|
||||||
"repo": "nixpkgs",
|
"repo": "flake-utils",
|
||||||
"rev": "e764fc9a405871f1f6ca3d1394fb422e0a0c3951",
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "numtide",
|
||||||
"ref": "nixos-25.11",
|
"repo": "flake-utils",
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769909678,
|
"lastModified": 1771848320,
|
||||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
|
||||||
"owner": "nix-community",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs",
|
||||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-community",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs.lib",
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -85,14 +70,14 @@
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"sd-card": "sd-card"
|
"sd-card": "sd-card"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sd-card": {
|
"sd-card": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils_2",
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
|
|
@ -123,6 +108,21 @@
|
||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"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",
|
"root": "root",
|
||||||
|
|
|
||||||
176
flake.nix
176
flake.nix
|
|
@ -1,180 +1,34 @@
|
||||||
{
|
{
|
||||||
description = "SolidHaus — Local-first household inventory app";
|
description = "solidhaus dev shell";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
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";
|
sd-card.url = "git+ssh://git@git.toph.so/toph/sd-card";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = inputs @ {
|
outputs = { self, nixpkgs, flake-utils, sd-card }:
|
||||||
flake-parts,
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
nixpkgs,
|
let
|
||||||
sd-card,
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
...
|
in
|
||||||
}:
|
{
|
||||||
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 {
|
|
||||||
packages = {
|
packages = {
|
||||||
# Build the SvelteKit app
|
|
||||||
solidhaus = buildNpmPackage {
|
|
||||||
pname = "solidhaus";
|
|
||||||
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/solidhaus";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# OCI image with nginx serving the built app
|
|
||||||
solidhaus-image = pkgs.dockerTools.buildLayeredImage {
|
|
||||||
name = "registry.toph.so/solidhaus";
|
|
||||||
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.solidhaus}/* 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
|
# nix run .#dev — start SvelteKit dev server with annotation proxy
|
||||||
dev = sd-card.lib.${system}.mkDevApp {
|
dev = sd-card.lib.${system}.mkDevApp {
|
||||||
inherit pkgs;
|
inherit pkgs;
|
||||||
devCmd = "${nodejs}/bin/npm run dev";
|
devCmd = "npm run dev";
|
||||||
devPort = 5173; # Vite default port
|
devPort = 5173; # Vite default port
|
||||||
};
|
};
|
||||||
|
|
||||||
default = config.packages.solidhaus;
|
|
||||||
};
|
|
||||||
|
|
||||||
apps = {
|
|
||||||
# Push image to registry
|
|
||||||
push-solidhaus-image = {
|
|
||||||
type = "app";
|
|
||||||
program = pkgs.lib.getExe (pkgs.writeShellApplication {
|
|
||||||
name = "push-solidhaus-image";
|
|
||||||
runtimeInputs = [pkgs.skopeo];
|
|
||||||
text = ''
|
|
||||||
image=$(nix build --no-link --print-out-paths .#solidhaus-image)
|
|
||||||
skopeo copy \
|
|
||||||
--insecure-policy \
|
|
||||||
"docker-archive:$image" \
|
|
||||||
"docker://registry.toph.so/solidhaus:latest"
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = sd-card.packages.${system}.tools # tea, jq, playwright, nodejs
|
||||||
|
++ (with pkgs; [
|
||||||
nodejs
|
nodejs
|
||||||
nodePackages.npm
|
nodePackages.npm
|
||||||
];
|
]);
|
||||||
inputsFrom = [sd-card.packages.${system}.tools];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
5500
package-lock.json
generated
5500
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -39,7 +39,6 @@
|
||||||
"@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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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,/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
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';
|
||||||
|
|
@ -27,8 +25,6 @@
|
||||||
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;
|
||||||
|
|
@ -59,11 +55,6 @@
|
||||||
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}
|
||||||
|
|
@ -163,27 +154,6 @@
|
||||||
</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