Compare commits
263 Commits
e7d62c29e1
...
claude/inf
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b6060872 | |||
| 5be6fd9047 | |||
| 3e164c48d0 | |||
| 259adb6a41 | |||
| ccf2c6b7c7 | |||
| a13e171295 | |||
| 73c36b4817 | |||
| 82f2a40269 | |||
| f6c0f7a507 | |||
| 1df16a6142 | |||
| 5da1022ed1 | |||
|
|
da63ad769a | ||
|
|
86e38e152d | ||
|
|
3757eb0170 | ||
|
|
cfd0c6aa81 | ||
| 243e5d3831 | |||
| 14a1b4e54e | |||
| 7df1c04c0f | |||
| 8f97590d9f | |||
| 4c08eba27a | |||
| 6c96c22b33 | |||
| 70dc1b6bdf | |||
| e3d4f6d92e | |||
| 9fc650fb12 | |||
| 29f7ad7073 | |||
| c134afe023 | |||
| a54d90a2d9 | |||
| 97f51a05c4 | |||
| d1ddc48f81 | |||
| 55c870c155 | |||
| 981328faff | |||
| e1707c314d | |||
| 64c0da794d | |||
| 0f4e2e0b8f | |||
| ea1803ec2f | |||
| fc5f30fad1 | |||
| a7b8a60473 | |||
| 24dfa996b3 | |||
| e364e4128f | |||
| d2a76fa58c | |||
| 0d5f380948 | |||
| 680fa90cbe | |||
| 9bba097d91 | |||
| 155e9bb126 | |||
| c783f129cc | |||
| 24c449f475 | |||
| e36f1939c6 | |||
|
|
2a75896274 | ||
|
|
85847848c4 | ||
|
|
b0c8c06b2b | ||
|
|
986dab7334 | ||
| 829d7bed83 | |||
| d70244f17e | |||
| a38c3c6e9b | |||
|
|
8ac406ac1f | ||
| 70de4b65ec | |||
| 79a9ef49d4 | |||
| bd39d310ab | |||
|
|
f9f276a986 | ||
|
|
ca9f6e6bd2 | ||
| 9d955d6b94 | |||
| af96922e89 | |||
| ccbc1050de | |||
|
|
cfd85a1fe8 | ||
| aa63d4c11d | |||
| 79ec511482 | |||
|
|
505fef5dfc | ||
|
|
8083328f2d | ||
| e669e80a91 | |||
|
|
3d8e050af4 | ||
|
|
cce1d876c3 | ||
| 7e10f0b462 | |||
| 6b8c7abc20 | |||
|
|
9a3ad3e579 | ||
|
|
4abe6d758c | ||
| cb5bc120dc | |||
| a72619e3d1 | |||
| 84e433ff63 | |||
| d35de8a2a9 | |||
| d5e4f23c0f | |||
| 5153aeebec | |||
| 4362e475a7 | |||
| 032dce6c85 | |||
| 0766b47bb2 | |||
| 17df1ecd09 | |||
| 8c2526d982 | |||
|
|
423a73a8a6 | ||
|
|
a27267f7ad | ||
|
|
6915c30911 | ||
|
|
4e1af0f356 | ||
|
|
5e2823ae94 | ||
|
|
99edd7d395 | ||
|
|
ab3c55dd96 | ||
|
|
c30b387dc3 | ||
|
|
347f58b4f3 | ||
|
|
68bbe6f024 | ||
|
|
c4df92720b | ||
|
|
2a5270d399 | ||
|
|
1b6ff45726 | ||
|
|
09c12ea35b | ||
|
|
abe5c1ec6b | ||
|
|
ea08d7e3e8 | ||
|
|
356062ead3 | ||
|
|
7a1c6b618b | ||
|
|
5a67ed7e7b | ||
|
|
f4618ddf97 | ||
|
|
2a5759cc8d | ||
|
|
f27f3cb820 | ||
|
|
f4a03b63f4 | ||
|
|
94088253f8 | ||
|
|
f657e2c0ba | ||
|
|
362fbc6d83 | ||
|
|
d24c64e0a0 | ||
|
|
18dce33fa3 | ||
|
|
28738acb56 | ||
|
|
576b0c236d | ||
|
|
9e9b1ba224 | ||
|
|
11202a92e7 | ||
|
|
a0d275490b | ||
|
|
6311412fc0 | ||
|
|
611d445d92 | ||
|
|
f14ea69223 | ||
|
|
f290784bf3 | ||
|
|
f743733edd | ||
|
|
c69322a89d | ||
|
|
f63daf16a8 | ||
|
|
d580796dc8 | ||
|
|
7b153f0d28 | ||
|
|
76551d898b | ||
|
|
89f99d220a | ||
|
|
63dd39d172 | ||
|
|
09a00f7419 | ||
|
|
99d504842e | ||
|
|
eeeb16a0f1 | ||
|
|
1695eae226 | ||
|
|
172f869e15 | ||
|
|
1408ec3fa3 | ||
|
|
b1c3ee68dd | ||
|
|
4bb2c959a8 | ||
|
|
758fda619e | ||
|
|
0eee0cecde | ||
|
|
4b137989c6 | ||
|
|
f811aae641 | ||
|
|
e4127f1e04 | ||
|
|
4c7e981daf | ||
|
|
65f6c14487 | ||
|
|
bc0bb44c05 | ||
|
|
80dd9d0a8b | ||
| e8e1adcf37 | |||
| 319be20389 | |||
| 11343c17dc | |||
| 3c022cef68 | |||
| b6be24c357 | |||
| b518a293cd | |||
| b7f2eb7832 | |||
| 96624bf853 | |||
| b9916866b3 | |||
| 68d8a6d064 | |||
| e6ca78881f | |||
|
|
c5582ffc65 | ||
|
|
eac005109c | ||
|
|
45f1649979 | ||
|
|
66b3bc5e1f | ||
|
|
d72a218b46 | ||
| 744a7fa0c3 | |||
| 967cf23b82 | |||
| a073ee7fb9 | |||
| e65e798021 | |||
| 7efac4354e | |||
| 8529771122 | |||
| a6eb406052 | |||
| 574d71371f | |||
|
|
5c531f11da | ||
|
|
800ee447ad | ||
|
|
7c68e1400b | ||
|
|
ecd8bbe61d | ||
|
|
2adaaf18a1 | ||
|
|
edd569d095 | ||
|
|
f090a809a9 | ||
|
|
e974ffbfd2 | ||
| b7aa2aed3d | |||
| 180efb3eb0 | |||
| fbeefb24a0 | |||
| 35d87a2749 | |||
| d693569f59 | |||
| fc8dd370c2 | |||
| bbb8e4850c | |||
| 94c07b1d0d | |||
| c7dd03ade2 | |||
| 8f41a23c41 | |||
| 348e9c3585 | |||
|
|
03a4c75e3e | ||
|
|
d933ea3842 | ||
| 6457dc9da5 | |||
| 38d975e354 | |||
| 64e4ea8083 | |||
| dcf6c11a22 | |||
| 30d03d8d4d | |||
|
|
339e1be510 | ||
|
|
07b1e5e02a | ||
|
|
afe420c620 | ||
| 532932057f | |||
| a4f64fad58 | |||
| 4bc6dc1dc8 | |||
| fa9b21856f | |||
| f48e099bd2 | |||
| 40d570f2c2 | |||
| 5f3fa5240e | |||
| aba061dd43 | |||
| 229611b4c3 | |||
| db20cbb7d8 | |||
| 17a69a271e | |||
| 7558aeb5e6 | |||
| 2a6661f779 | |||
| 29177f0c1a | |||
| ad007e4cd4 | |||
| 1ebc4bfe73 | |||
| 842a4fc864 | |||
| 649437dc85 | |||
| b1c2bdf7f0 | |||
| 9282a5c734 | |||
| dea04ceed9 | |||
| f01b5ed77e | |||
| b2ebf401bb | |||
| 57788f60c5 | |||
| 7cf546af7a | |||
| 9c9f5bd899 | |||
| 1c6383e5a0 | |||
| c973ca0443 | |||
| 870e7dd3d3 | |||
| 30cbcb8cd1 | |||
| 03b99a40b0 | |||
| b993a188d3 | |||
| 367d1277b8 | |||
| 7d1149aabd | |||
| 8f25c58b74 | |||
| 12a616d4b5 | |||
| 2e7158a5bb | |||
| 938c5dce68 | |||
| 6d8e92940a | |||
| f4c51f6f92 | |||
| 64b84e2069 | |||
| 733c16bf82 | |||
| e51b93d59e | |||
| d6c667ae78 | |||
|
|
a4b1802657 | ||
|
|
ce25a4460e | ||
|
|
872be4e0cf | ||
|
|
d27ec1af2e | ||
|
|
697343646a | ||
|
|
3df8add3b6 | ||
|
|
42250aa16e | ||
| 9ed577f40c | |||
| 6fa00f4b37 | |||
| e49f64659e | |||
| 9317730885 | |||
| eb16564b84 | |||
| 7bb03be51a | |||
| 123c60ed2c | |||
| e2ee9b691c | |||
| 57f510ad3d | |||
| 26424469db | |||
| ec4a14976d |
20
.env
20
.env
@@ -1,20 +0,0 @@
|
||||
# Mulita / PhotoVault local environment.
|
||||
# See .env.example for the full list of knobs and their docs.
|
||||
|
||||
# REQUIRED — host path to your photo library.
|
||||
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
|
||||
|
||||
# Ports — change if 3000 / 8001 collide with other services on the host.
|
||||
FRONTEND_PORT=3000
|
||||
BACKEND_PORT=8001
|
||||
REDIS_PORT=6379
|
||||
|
||||
# CORS — wildcard for local dev. Lock down for real deployments.
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# Logging + timezone.
|
||||
LOG_LEVEL=INFO
|
||||
TZ=UTC
|
||||
|
||||
# Celery worker pool.
|
||||
CELERYD_CONCURRENCY=4
|
||||
142
.env.example
142
.env.example
@@ -1,83 +1,83 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mulita / PhotoVault — example environment file
|
||||
# Example environment file. Copy to `.env` and adjust.
|
||||
#
|
||||
# Copy this file to `.env` and adjust the values for your setup. Every key
|
||||
# below has a sensible default in docker-compose.yml, so you only need to
|
||||
# uncomment the ones you actually want to change.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||
|
||||
|
||||
# ── REQUIRED ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host path to your photo library. The compose file mounts this at /photos
|
||||
# inside the backend + worker containers. The backend creates a default
|
||||
# source root pointing at /photos on first boot, so once this is set the
|
||||
# library is scanned with zero further configuration.
|
||||
# Host path to your photo library. PhotoPrism reads this in place and
|
||||
# writes EXIF backwrites next to originals (when PP_ORIGINALS_MODE=rw).
|
||||
PHOTO_DIRS=/mnt/library/homecloud/admin/files/
|
||||
|
||||
# Bootstrap admin password. The first PhotoPrism boot creates an `admin`
|
||||
# account with this password. Rotate after first login from the UI.
|
||||
PP_ADMIN_PASSWORD=please-change-me
|
||||
|
||||
# MariaDB passwords. Generate with `openssl rand -hex 24`.
|
||||
PP_DB_PASSWORD=please-change-me
|
||||
PP_DB_ROOT_PASSWORD=please-change-me
|
||||
|
||||
|
||||
# ── OPTIONAL ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Loopback host port for PhotoPrism's API (and UI, if you tunnel to it).
|
||||
# Vite proxies /api/v1/* here and the host-mode sidecar reaches it on
|
||||
# localhost. Not published on the public interface.
|
||||
PP_PORT=2342
|
||||
|
||||
# Site URL — used for share links, OIDC redirect URI, and reverse-proxy aware
|
||||
# URL generation. Set to the public hostname once the proxy is in front.
|
||||
PP_SITE_URL=http://localhost:2342/
|
||||
|
||||
# Auth mode — "password" for username/password (default), "public" for an
|
||||
# unauthenticated kiosk mode (don't use this on a multi-user library).
|
||||
PP_AUTH_MODE=password
|
||||
|
||||
# Library mount mode. "rw" allows rename / folder mutations / EXIF backwrite;
|
||||
# "ro" is safe-for-archives but disables those sidecar endpoints. Set in
|
||||
# lockstep with PP_READONLY below.
|
||||
PP_ORIGINALS_MODE=rw
|
||||
PP_READONLY=false
|
||||
|
||||
# UID/GID inside the PhotoPrism container. Set these to the host UID/GID that
|
||||
# owns ${PHOTO_DIRS}. `id -u` and `id -g`.
|
||||
PP_UID=1000
|
||||
PP_GID=1000
|
||||
|
||||
|
||||
# ── OIDC SSO (Authentik or equivalent) ───────────────────────────────────────
|
||||
# Leave blank to keep OIDC dormant. Fill in to enable the "Sign in with OIDC"
|
||||
# button on the login page; OIDC_REGISTER=true auto-creates accounts at role
|
||||
# `user` (override to `admin` to grant full access on first SSO login).
|
||||
#
|
||||
# Examples:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
PHOTO_DIRS=./photos
|
||||
# The compose file reads these and maps them to PhotoPrism's actual env-var
|
||||
# names (PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER) — see the
|
||||
# comment in docker-compose.yml. The PhotoPrism callback URI is auto-derived
|
||||
# from PP_SITE_URL; do not set it manually.
|
||||
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
# OIDC_ISSUER_URL=https://auth.example.com/application/o/photoprism/
|
||||
# OIDC_CLIENT_ID=...
|
||||
# OIDC_CLIENT_SECRET=...
|
||||
# OIDC_SCOPES=openid profile email
|
||||
# OIDC_REGISTER=true
|
||||
# OIDC_ROLE=user
|
||||
|
||||
|
||||
# ── PORTS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host port the SPA is served on. Browse to http://<host>:<FRONTEND_PORT>/.
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# Host port for the backend API. Almost never needed directly — the frontend
|
||||
# nginx proxies /api/ to the backend over the internal compose network. Kept
|
||||
# exposed for debugging / curl.
|
||||
BACKEND_PORT=8001
|
||||
|
||||
# Redis host port. Internal services reach Redis on its container name; this
|
||||
# is just for local debugging.
|
||||
REDIS_PORT=6379
|
||||
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Comma-separated list of allowed origins for direct browser access to the
|
||||
# backend. Same-origin requests through the nginx / vite proxy never trip
|
||||
# CORS, so this only matters when something hits the backend port directly
|
||||
# from a different origin (e.g. another machine, dev tools, a reverse proxy
|
||||
# under a different hostname).
|
||||
# ── USER LIBRARY ISOLATION ───────────────────────────────────────────────────
|
||||
# Maps PhotoPrism usernames to originals-relative subdirectories so each
|
||||
# user only sees their own photos. Format: comma-separated user:path pairs.
|
||||
# The sidecar reconciler applies this to auth_users.base_path on boot and
|
||||
# every 60s. Leave empty for single-user deployments.
|
||||
#
|
||||
# Default "*" is permissive, fine for a single-user homelab. Lock it down in
|
||||
# real deployments:
|
||||
# ALLOWED_ORIGINS=https://photos.example.com
|
||||
# ALLOWED_ORIGINS=https://photos.example.com,http://192.168.1.10:3000
|
||||
ALLOWED_ORIGINS=*
|
||||
# USER_BASEPATHS="alice:alice, bob:bob"
|
||||
|
||||
# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first
|
||||
# boot. Rotate before any non-local deployment.
|
||||
# SIDECAR_DB_PASSWORD=replace-at-m4-bringup
|
||||
|
||||
|
||||
# ── LOGGING / TIMEZONE ───────────────────────────────────────────────────────
|
||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Python log level for the backend and Celery worker. Bump to DEBUG when
|
||||
# chasing scan / thumbnail issues.
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Container timezone. Affects the timestamps in logs and the "added at"
|
||||
# field on newly imported photos. Defaults to UTC.
|
||||
# TZ=Europe/Berlin
|
||||
# TZ=America/New_York
|
||||
TZ=UTC
|
||||
|
||||
|
||||
# ── WORKER CONCURRENCY ───────────────────────────────────────────────────────
|
||||
|
||||
# How many parallel Celery worker processes to spin up. Each one can run
|
||||
# one scan / thumbnail / metadata job at a time. Bump on a beefy host with a
|
||||
# big library; lower on a Pi.
|
||||
CELERYD_CONCURRENCY=4
|
||||
|
||||
|
||||
# ── INTERNAL (rarely overridden) ─────────────────────────────────────────────
|
||||
|
||||
# These point at the in-compose Redis and the bind-mounted SQLite db. Override
|
||||
# only if you're running Mulita without docker-compose or against an external
|
||||
# Redis.
|
||||
# REDIS_URL=redis://redis:6379
|
||||
# CELERY_BROKER_URL=redis://redis:6379
|
||||
# CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
# DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
PP_LOG_LEVEL=info
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -34,6 +34,7 @@ dist-ssr/
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -60,9 +61,18 @@ build/
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# PhotoPrism state (sidecars, cache, thumbs, db backups) — regenerable.
|
||||
/pp/storage/
|
||||
/pp/import/
|
||||
|
||||
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
||||
/sidecar/data/
|
||||
|
||||
# Sidecar Go build output.
|
||||
/sidecar/mule-sidecar
|
||||
|
||||
# Photos (for development)
|
||||
/photos/
|
||||
|
||||
# Thumbnails
|
||||
/thumbs/
|
||||
/trash/
|
||||
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Plan: Populate `photos_users` to fix label isolation in PhotoPrism
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the label isolation leak where a user with `base_path` set (e.g. `muli`) sees photos from other users' directories (e.g. `dtoro`) in PhotoPrism's labels view.
|
||||
|
||||
## 2. Current Context
|
||||
|
||||
### The problem
|
||||
- PhotoPrism's `base_path` feature correctly scopes the main search (`/api/v1/photos`).
|
||||
- Label views (`/api/v1/labels`) do **not** respect `base_path` — labels show photo counts and thumbnails from the entire library.
|
||||
- User reports: "all photos on the main labels page are a mix of both" muli and dtoro.
|
||||
|
||||
### What we know
|
||||
- **53 files changed** in the sidecar (Go + GORM, gorm.io/gorm v1.31.1).
|
||||
- Sidecar has a working PhotoPrism DB connection via `PpDSN` (user: `photoprism`, schema: `photoprism.*`).
|
||||
- The `photos_users` table exists in PhotoPrism's MariaDB schema but is **empty** (0 rows).
|
||||
- Schema of `photos_users`:
|
||||
|
||||
```
|
||||
photos_users:
|
||||
uid varbinary(42) NOT NULL PRI (composite PK or single?)
|
||||
user_uid varbinary(42) NOT NULL PRI
|
||||
team_uid varbinary(42) YES MUL
|
||||
perm int(10) unsigned YES
|
||||
```
|
||||
|
||||
- Known user UIDs: `dtoro=utfetfdk0so2z9zl`, `muli=utg7jjbd8iwaghn6`
|
||||
- Known base paths: `dtoro→dtoro`, `muli→muli`
|
||||
- The `photo_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/Memories/...`
|
||||
- Currently: ~88K photos, ~52K files indexed.
|
||||
|
||||
### The sidecar's current reconciler (`users.go`)
|
||||
- Runs every 60s.
|
||||
- Only calls `UPDATE auth_users SET base_path = ? WHERE user_name = ?`.
|
||||
- Does **not** touch `photos_users`.
|
||||
|
||||
### Unknowns
|
||||
1. **Does PhotoPrism use `photos_users` for general label filtering?** The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignore `photos_users` in label queries.
|
||||
2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries.
|
||||
3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow.
|
||||
4. **`perm` values**: Unclear what `perm` value grants "view" access. Likely a bitmap (bit 0 = view).
|
||||
|
||||
## 3. Proposed Approach
|
||||
|
||||
### Phase 1: Investigate (prove the approach works before building)
|
||||
|
||||
**Step 1.1: Insert test rows into `photos_users` manually**
|
||||
|
||||
On the production DB, insert a few `photos_users` entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed `perm` value (e.g. `1` = view).
|
||||
|
||||
Then check:
|
||||
- Does muli see fewer photos now? (If `photos_users` works as an exclusive ACL, yes.)
|
||||
- Does the dtoro photo with a `photos_users` entry for muli show up for muli?
|
||||
- Does the label view change?
|
||||
|
||||
**Step 1.2: Test with `perm` variations**
|
||||
|
||||
If `perm=1` does nothing, try `perm=2`, `perm=7`, or `perm=15` (common Unix-ish bitmap patterns).
|
||||
|
||||
**Step 1.3: Examine PhotoPrism source**
|
||||
|
||||
Check PhotoPrism's search/label code to confirm whether `photos_users` is joined in label queries. This tells us definitively whether the approach is viable.
|
||||
|
||||
### Phase 2: Build (if Phase 1 confirms the approach works)
|
||||
|
||||
**Step 2.1: Add `photos_users` GORM model**
|
||||
|
||||
New struct in `db.go` or a new file `perms.go`:
|
||||
|
||||
```go
|
||||
type PhotoUser struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:42;column:uid"`
|
||||
UserUID string `gorm:"primaryKey;size:42;column:user_uid"`
|
||||
TeamUID string `gorm:"size:42;column:team_uid"`
|
||||
Perm int `gorm:"column:perm"`
|
||||
}
|
||||
|
||||
func (PhotoUser) TableName() string { return "photos_users" }
|
||||
```
|
||||
|
||||
Note: GORM `AutoMigrate` is called on `mule_sidecar` schema, not `photoprism.*`. The `photos_users` table already exists in the `photoprism` schema — we only query/insert, never migrate.
|
||||
|
||||
**Step 2.2: Add `reconcilePhotoUsers` function**
|
||||
|
||||
New function in a new file `perms.go` alongside `users.go`. Signature:
|
||||
|
||||
```go
|
||||
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`.
|
||||
2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`.
|
||||
3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1).
|
||||
4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency.
|
||||
5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up.
|
||||
|
||||
**Step 2.3: Wire into reconciler loop**
|
||||
|
||||
Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`.
|
||||
|
||||
```go
|
||||
func apply() {
|
||||
reconcileUserBasepaths(...)
|
||||
reconcilePhotoUsers(...)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2.4: Handle re-index edge cases**
|
||||
|
||||
- When new photos are indexed, they won't have `photos_users` entries until the next 60s tick.
|
||||
- Could add a webhook or a one-shot trigger after PhotoPrism's index completes.
|
||||
- Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag).
|
||||
|
||||
### Phase 3: Validate (if Phase 1 confirms)
|
||||
|
||||
1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
|
||||
2. Rebuild the Docker image and restart the sidecar.
|
||||
3. Check `photos_users` has expected rows.
|
||||
4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone.
|
||||
5. Log in as `dtoro` — verify still sees own photos.
|
||||
6. Verify no regression: search, album, folder views still work for both users.
|
||||
|
||||
## 4. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sidecar/perms.go` | **New file** — `PhotoUser` model, `reconcilePhotoUsers` function |
|
||||
| `sidecar/db.go` | Add `photos_users`-related constants/helpers (optional) |
|
||||
| `sidecar/users.go` | Extend `reconcileUserBasepaths` or add a phase to the existing reconciler |
|
||||
| `sidecar/main.go` | Wire the new reconciler phase (minor — call from existing ticker) |
|
||||
| `sidecar/Dockerfile` | Unchanged (Go build picks up new `.go` files automatically) |
|
||||
|
||||
## 5. Tests & Validation
|
||||
|
||||
1. **Build check**: `go build ./...` from `sidecar/`
|
||||
2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...`
|
||||
3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user.
|
||||
4. **Label isolation check**: Browse labels as each user — confirm no cross-user leaks.
|
||||
|
||||
## 6. Source Code Analysis (Completed)
|
||||
|
||||
### How base_path scoping works in PhotoPrism
|
||||
|
||||
Found the critical function `ScopePhotosForSession` in `internal/entity/search/photos_scope.go`:
|
||||
|
||||
```go
|
||||
func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB {
|
||||
// Admin/library role → no scoping needed
|
||||
if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
|
||||
return stmt
|
||||
}
|
||||
user := sess.GetUser()
|
||||
if basePath := user.GetBasePath(); basePath == "" {
|
||||
return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...)
|
||||
} else {
|
||||
return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?",
|
||||
..., basePath, basePath + "/%")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key: base_path filtering is done by adding `WHERE photos.photo_path LIKE 'muli/%'` to the SQL query. It is **NOT** done via `photos_users`.
|
||||
|
||||
### How endpoints use base_path
|
||||
|
||||
| Endpoint | Function | Applies base_path? |
|
||||
|----------|----------|-------------------|
|
||||
| `GET /api/v1/photos` | `SearchPhotos` → `UserPhotos` → `searchPhotos` → **`ScopePhotosForSession`** | ✅ Yes |
|
||||
| `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — no session passed | ❌ **No** |
|
||||
| Review tab | Uses `GET /api/v1/photos?quality=3` → goes through `ScopePhotosForSession` | ✅ Should scope |
|
||||
| Archive tab | Uses `GET /api/v1/photos?archived=true` → goes through `ScopePhotosForSession` | ✅ Should scope |
|
||||
| Albums | TBD — depends on whether they use `ScopePhotosForSession` | ⚠️ Unknown |
|
||||
|
||||
### The `photos_users` table
|
||||
|
||||
Found in `internal/entity/photo_user.go`:
|
||||
|
||||
```go
|
||||
type PhotoUser struct {
|
||||
UID string // photo_uid
|
||||
UserUID string // user_uid
|
||||
TeamUID string // team_uid
|
||||
Perm uint // permission bitmap
|
||||
}
|
||||
```
|
||||
|
||||
This table is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. It is only used for **explicit sharing** (via `FirstOrCreatePhotoUser` called when sharing a specific photo with another user).
|
||||
|
||||
**Conclusion: Populating `photos_users` will NOT fix the label, review, or archive tab isolation.** PhotoPrism does not consult this table for any of these queries.
|
||||
|
||||
### Why review/archive might show cross-user photos
|
||||
|
||||
Since review and archive use `GET /api/v1/photos` which goes through `ScopePhotosForSession`, they **should** be scoped. The issue might be:
|
||||
1. **Pre-computed counts** in the sidebar tabs show total numbers across all users
|
||||
2. **Label thumbnails** and category summaries are computed from the `labels` table which is global
|
||||
3. The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts
|
||||
|
||||
### DB experiment results
|
||||
|
||||
Confirmed `photos_users` is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with `perm=1`. Label API response unchanged — `photo_count` values remained the same (Dog: 733, Cat: 57), confirming labels ignore `photos_users`.
|
||||
|
||||
## 7. Updated Recommendation
|
||||
|
||||
**Abandon the `photos_users` approach.** It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries.
|
||||
|
||||
### Real fix options
|
||||
|
||||
1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that:
|
||||
- Accepts the caller's `X-Auth-Token` (already validated by `requireSession`)
|
||||
- Forwards the request to PhotoPrism's `/api/v1/labels`
|
||||
- **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path`
|
||||
- Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label)
|
||||
- Also filter `Count` values in the sidebar summary response
|
||||
|
||||
**Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path.
|
||||
|
||||
2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
|
||||
|
||||
3. **Accept the limitation** — Labels show cross-user thumbnails/counts but the actual photo list is scoped.
|
||||
|
||||
### Implementation sketch for option 1
|
||||
|
||||
```
|
||||
sidecar/
|
||||
├── proxy.go # New file
|
||||
│ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path
|
||||
│ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count
|
||||
│ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count
|
||||
```
|
||||
|
||||
The SvelteKit frontend would call `/api/sidecar/labels` instead of `/api/v1/labels`.
|
||||
|
||||
### Clean up: remove test rows from photos_users
|
||||
|
||||
Since the approach won't work, remove the test rows inserted during Phase 1:
|
||||
|
||||
```sql
|
||||
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';
|
||||
```
|
||||
@@ -0,0 +1,301 @@
|
||||
# Plan: Fix user isolation in PhotoPrism — labels, review, and archive views
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the three views where a user with `base_path` set (e.g. `muli`) sees photos from other users (e.g. `dtoro`):
|
||||
|
||||
1. **Labels** — labels list + label drill-down show all library photos
|
||||
2. **Review** — photos needing review tab shows cross-user photos
|
||||
3. **Archive** — archived photos tab shows cross-user photos
|
||||
|
||||
## 2. Current Context & Source Analysis
|
||||
|
||||
### 2.1 How base_path scoping works
|
||||
|
||||
PhotoPrism's `ScopePhotosForSession` (in `internal/entity/search/photos_scope.go`) is the only function that enforces user isolation. It adds `WHERE photos.photo_path = '<basePath>' OR photos.photo_path LIKE '<basePath>/%'` to the SQL query.
|
||||
|
||||
This is called by `searchPhotos()` — used by the **`GET /api/v1/photos`** endpoint (search, timeline, folders).
|
||||
|
||||
### 2.2 How endpoints use base_path
|
||||
|
||||
| View | Endpoint | Function chain | Applies base_path? |
|
||||
|------|----------|----------------|-------------------|
|
||||
| Main timeline | `GET /api/v1/photos` | `SearchPhotos` → `searchPhotos` → `ScopePhotosForSession` | ✅ Yes |
|
||||
| Folders | `GET /api/v1/photos` with path filter | Same chain | ✅ Yes |
|
||||
| **Labels** | `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — **no session** | ❌ **No** — queries `labels` table directly |
|
||||
| Label drill-down | `GET /api/v1/photos?label=X` | Uses `searchPhotos` → `ScopePhotosForSession` | ✅ Should scope (if label= param doesn't bypass) |
|
||||
| **Review tab** | `GET /api/v1/photos?q=review:true` | `searchPhotos` → `ScopePhotosForSession` | ✅ Should scope, BUT... |
|
||||
| **Archive tab** | `GET /api/v1/photos?q=archived:true` | Same | ✅ Should scope, BUT... |
|
||||
|
||||
### 2.3 The review/archive problem: ACL overrides
|
||||
|
||||
In `searchPhotosForm()` (internal/api/photos_search.go):
|
||||
|
||||
```go
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
|
||||
frm.Quality = 3
|
||||
}
|
||||
```
|
||||
|
||||
For role=user, `Deny(ActionManage)` → true → sets `frm.Quality = 3` (minimum quality).
|
||||
|
||||
Then in `searchPhotos()` (internal/entity/search/photos.go):
|
||||
|
||||
```go
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) {
|
||||
frm.Archived = false
|
||||
frm.Review = false
|
||||
}
|
||||
```
|
||||
|
||||
For role=user, `Deny(ActionDelete)` → true → **overrides `review:true` and `archived:true` to false**.
|
||||
|
||||
So the review and archive filters are **completely ignored** for the `user` role. The frontend sends `review:true` but the server discards it. The result: the review/archive tabs show ALL photos scoped by base_path (no quality/review/archive filter), which means basically the same as the main timeline.
|
||||
|
||||
### 2.4 Label problem: no session scoping at all
|
||||
|
||||
`search.Labels()` queries the `labels` table directly with a `WHERE photo_count > 0` clause. There is no session parameter, no `ScopePhotosForSession`, and no base_path or user filtering whatsoever. Labels are **library-wide** in PhotoPrism.
|
||||
|
||||
The label drill-down (click into a label) uses `GET /api/v1/photos?label=X` which DOES go through `ScopePhotosForSession`, so the photo list itself should be scoped — but the label thumbnails, counts, and covers are global.
|
||||
|
||||
### 2.5 ACL GrantDefaults — missing RoleUser entry
|
||||
|
||||
```go
|
||||
var GrantDefaults = Roles{
|
||||
RoleAdmin: GrantFullAccess, // FullAccess = AccessLibrary + everything
|
||||
RoleGuest: GrantReactShared, // Only shared content
|
||||
RoleVisitor: GrantViewShared, // Only shared content
|
||||
RolePortal: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
// RoleUser and RoleViewer are NOT listed → fallback to RoleDefault (also missing) → denied
|
||||
}
|
||||
```
|
||||
|
||||
Because `RoleUser` is absent from `GrantDefaults`, the `Allow()` function falls back to `RoleDefault` which is also absent → returns `false` for all permissions. This means:
|
||||
- `ScopePhotosForSession` correctly enters the `base_path` branch (good — user is isolated)
|
||||
- BUT `ActionDelete` is denied → review/archive filters are forced off (bad — can't browse review/archive)
|
||||
|
||||
### 2.6 The `photos_users` table — ruled out
|
||||
|
||||
`internal/entity/photo_user.go` defines `PhotoUser` but it is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. Populating it won't fix any of these issues.
|
||||
|
||||
## 3. Proposed Approach
|
||||
|
||||
### Phase 1: Sidecar proxy for labels (direct fix)
|
||||
|
||||
Extend the sidecar to expose a **scoped labels endpoint**:
|
||||
|
||||
```
|
||||
GET /api/sidecar/labels → proxies to GET /api/v1/labels → filters by base_path
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Sidecar receives the caller's `X-Auth-Token`
|
||||
2. `requireSession` middleware resolves the token → returns user's `BasePath`
|
||||
3. Sidecar makes the same `/api/v1/labels` request to PhotoPrism (using the caller's token)
|
||||
4. **Filter step**: for each label in the response, query the DB to count photos with that label AND `photo_path LIKE '<base_path>/%'`
|
||||
5. Return filtered labels with corrected `PhotoCount` and `Thumb`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
New file `sidecar/handlers_labels.go`:
|
||||
|
||||
```go
|
||||
// handleLabels proxies to PP's /api/v1/labels, then post-filters
|
||||
// counts and thumbnails by the caller's base_path.
|
||||
func handleLabels(pp *ppClient, ppDSN string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
user := ctxUser(c) // resolved from session, includes BasePath
|
||||
|
||||
// 1. Get raw labels from PhotoPrism
|
||||
resp, _ := pp.call(c, "GET", "/api/v1/labels?"+c.Request.URL.RawQuery, token, nil)
|
||||
|
||||
// 2. Decode labels
|
||||
var labels []PpLabel
|
||||
json.Unmarshal(resp.Body, &labels)
|
||||
|
||||
// 3. For each label, recalculate count for this user's base_path
|
||||
for i, l := range labels {
|
||||
// Count photos with this label AND where photo_path matches base_path
|
||||
var count int
|
||||
db.Raw(`SELECT COUNT(*) FROM photos_labels pl
|
||||
JOIN photos p ON pl.photo_uid = p.photo_uid
|
||||
WHERE pl.label_uid = ? AND p.photo_path LIKE ?`,
|
||||
l.UID, user.BasePath+"/%").Scan(&count)
|
||||
labels[i].PhotoCount = count
|
||||
|
||||
// If count is 0, the thumb from the global label doesn't apply
|
||||
// Could also update thumb to a user-scoped one
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, labels)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend change:** Update the label query in `web/src/routes/tags/[category]/[[value]]/+page.svelte` to call `/api/sidecar/labels` instead of `/api/v1/labels`.
|
||||
|
||||
### Phase 2: Fix review/archive — skip the ACL override
|
||||
|
||||
Two options:
|
||||
|
||||
**Option A (Recommended): Sidecar proxy for photos search**
|
||||
|
||||
Extend the sidecar with:
|
||||
```
|
||||
GET /api/sidecar/photos → proxies to GET /api/v1/photos → adds path filter
|
||||
```
|
||||
|
||||
The sidecar intercepts the photos request and adds the `path:<basePath>` query parameter to PhotoPrism's API call. This forces PhotoPrism to add `WHERE photo_path LIKE '<base_path>/%'`.
|
||||
|
||||
For review/archive, the sidecar also adds `review:true` or `archived:true` BEFORE the ACL override happens (since the sidecar doesn't hit the ACL code).
|
||||
|
||||
**Option B: Custom frontend query**
|
||||
|
||||
The frontend explicitly adds `path:muli` to the query string for review/archive tabs:
|
||||
```
|
||||
GET /api/v1/photos?q=review:true path:muli&count=50
|
||||
```
|
||||
|
||||
The `path` filter is a standard PhotoPrism search operator that adds `WHERE photos.photo_path = '<path>'`. But this only matches the exact path, not `path/%` (subdirectories). The `path:` operator does `photo_path = ?` (exact match) per the code at line 668.
|
||||
|
||||
**Option A is better** because:
|
||||
- Works for all users without frontend changes
|
||||
- Can add the proper `LIKE` prefix match
|
||||
- Centralized logic in the sidecar
|
||||
|
||||
### Phase 3: Sidecar proxy for sidebar counts
|
||||
|
||||
The session response (or `GET /api/v1/config`) includes library-wide counts:
|
||||
```json
|
||||
"count": {
|
||||
"review": 248,
|
||||
"archived": 94,
|
||||
"all": 88203,
|
||||
"photos": 88000
|
||||
}
|
||||
```
|
||||
|
||||
These show the TOTAL across all users. The sidecar can proxy this and recalculate counts per base_path.
|
||||
|
||||
## 4. Step-by-step Plan
|
||||
|
||||
### Step 1: Sidecar — labels proxy
|
||||
|
||||
Files: `sidecar/handlers_labels.go` (new), `sidecar/main.go` (route wiring)
|
||||
|
||||
1. New types: `PpLabel` (mirrors PhotoPrism's label response shape)
|
||||
2. Handler function `handleLabels()` that:
|
||||
- Validates token via `requireSession`
|
||||
- Gets `BasePath` from session
|
||||
- Calls PhotoPrism's `/api/v1/labels`
|
||||
- For each label, queries photos_labels + photos to count user-scoped photos
|
||||
- Returns filtered labels
|
||||
3. Wire route: `auth.GET("/labels", handleLabels(...))` in `main.go`
|
||||
4. Frontend: change label fetch URL from `/api/v1/labels` to `/api/sidecar/labels`
|
||||
|
||||
### Step 2: Sidecar — photos proxy (review/archive fix)
|
||||
|
||||
Files: `sidecar/handlers_photos.go` (new), `sidecar/main.go` (route wiring)
|
||||
|
||||
1. Handler function `handlePhotos()` that:
|
||||
- Validates token
|
||||
- Gets `BasePath` from session
|
||||
- Parses the query string to detect `review:true` or `archived:true`
|
||||
- Forwards to PhotoPrism's `/api/v1/photos` with `path:<basePath>` added to query
|
||||
- For review/archive, also ensures `review/archived` filter is NOT stripped
|
||||
- Returns PhotoPrism's response
|
||||
|
||||
2. Two implementation variants:
|
||||
|
||||
**Variant A** (simpler): add `path:<basePath>` to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's `path:` operator does exact match). Might miss photos in subdirectories.
|
||||
|
||||
**Variant B** (correct): Forward the query without path, then post-filter the response to remove photos whose `photo_path` doesn't match `basePath/%`. This is more robust.
|
||||
|
||||
### Step 3: Validation
|
||||
|
||||
1. Build sidecar: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
|
||||
2. Rebuild Docker image: `docker compose build sidecar`
|
||||
3. Restart sidecar: `docker compose up -d sidecar`
|
||||
4. Test labels as muli — verify only muli's labels appear
|
||||
5. Test review tab as muli — verify only muli's photos needing review appear
|
||||
6. Test archive tab as muli — verify only muli's archived photos appear
|
||||
7. Test same views as admin — verify dtoro still sees all
|
||||
|
||||
## 5. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sidecar/handlers_labels.go` | **New** — label proxy handler |
|
||||
| `sidecar/handlers_photos.go` | **New** — photos proxy handler (or merged into one proxy.go) |
|
||||
| `sidecar/handlers_folder.go` | Reference for existing handler patterns |
|
||||
| `sidecar/main.go` | Wire new routes under `auth` group |
|
||||
| `sidecar/pp.go` | May need new helper methods for label/photo API calls |
|
||||
| `sidecar/users.go` | No change |
|
||||
| `sidecar/db.go` | May add types for PpLabel, PpPhoto |
|
||||
| `web/src/routes/tags/[category]/[[value]]/+page.svelte` | Change label fetch URL |
|
||||
| `web/src/lib/stores/filters.svelte.ts` | Possibly change how review/archive queries are built |
|
||||
|
||||
## 6. Tests & Validation
|
||||
|
||||
**Build**: `cd sidecar && go build ./... && go vet ./...`
|
||||
|
||||
**Manual validation on LXC 120:**
|
||||
|
||||
```bash
|
||||
# Test labels endpoint
|
||||
curl -s "http://localhost:8000/api/sidecar/labels?count=5" \
|
||||
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(l.get('Name','?'),l.get('PhotoCount')) for l in d[:5]]"
|
||||
|
||||
# Test photos endpoint with review
|
||||
curl -s "http://localhost:8000/api/sidecar/photos?q=review:true&count=5" \
|
||||
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'{len(d)} photos')"
|
||||
|
||||
# Verify vs. admin token — counts should differ
|
||||
```
|
||||
|
||||
**Cross-user check:** Log in as `muli` and `dtoro` in separate browser sessions. Verify:
|
||||
- Labels show different counts per user
|
||||
- Review photos are scoped per user
|
||||
- Archive photos are scoped per user
|
||||
|
||||
## 7. Risks, Tradeoffs & Open Questions
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Sidecar proxying adds latency | Slower page loads | Labels are small payloads; single DB query per label is fast |
|
||||
| Frontend needs URL changes | Breaks if not updated | Do frontend change alongside sidecar deploy |
|
||||
| Photo count queries on every label request | DB load | Cache results for 30s in the sidecar |
|
||||
| PhotoPrism's label `PhotoCount` is stale | Mismatch with actual count | Acceptable — PhotoPrism's count is already cached |
|
||||
| Review/archive fix depends on how PhotoPrism handles `path:` operator | Photos in subdirs missed | Use Variant B (post-filter by path prefix) |
|
||||
|
||||
### Open Questions
|
||||
|
||||
- **Q1**: For review/archive — is the user seeing dtoro's photos in the *grid* or only the *sidebar counts*? Need to verify actual API response vs what the frontend renders.
|
||||
- **Q2**: What's the performance impact of running `SELECT COUNT(*) FROM photos_labels ... JOIN photos ...` for every label in the response? (Labels list is typically short, < 100)
|
||||
- **Q3**: Does the frontend cache the label response aggressively? Need to invalidate cache on user switch.
|
||||
- **Q4**: For the `path:` operator — does it do exact match or LIKE? From source: `WHERE photos.photo_path = ?` — exact match only.
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- **Sidecar proxy vs. frontend-only**: Proxy centralizes logic but adds network hop. Frontend-only is faster but more complex (every route needs path filtering).
|
||||
- **Label count accuracy**: Recalculated per-user counts will differ from the library-wide counts. This is intentional — labels are scoped now.
|
||||
- **Sidecar vs. patching PhotoPrism**: Sidecar approach is non-invasive (no fork/build of PP). PhotoPrism patch would be cleaner but requires maintaining a fork.
|
||||
|
||||
## 8. Recommendation
|
||||
|
||||
1. **Build the labels proxy** (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
|
||||
2. **Investigate review/archive leak** first — run the actual API query as muli to confirm whether the photos search is actually scoped. The code analysis says it should be, but the user reports otherwise. If confirmed as a real leak, implement the sidecar photos proxy (Phase 2)
|
||||
3. **Sidebar counts** (Phase 3) — lower priority, can be done after labels and review/archive are fixed
|
||||
|
||||
Before building, confirm with the user whether they see cross-user photos in the actual grid or only in the sidebar counts for review/archive.
|
||||
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Plan: Fix remaining user isolation issues — 404 errors and folder tree
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the remaining issues after deploying the sidecar scoping proxy:
|
||||
|
||||
1. **404 on photo grid** — "Request failed with status code 404" in private window
|
||||
2. **Folder tree shows other users** — on first load, the library tree lists other users' folders; a refresh fixes it
|
||||
|
||||
## 2. Current Context
|
||||
|
||||
### What's deployed
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Sidecar labels proxy (`/api/sidecar/labels`) | ✅ Working |
|
||||
| Sidecar counts proxy (`/api/sidecar/counts`) | ✅ Working |
|
||||
| Sidecar timeline proxy (`/api/sidecar/timeline`) | ✅ Working through Caddy |
|
||||
| Caddy fallback for `/api/v1/api/sidecar/*` | ✅ Working |
|
||||
| Frontend rebuild with `sidecar` axios instance | ✅ Built and deployed |
|
||||
|
||||
### Verified working via Caddy
|
||||
|
||||
```bash
|
||||
# Through public URL with valid admin token
|
||||
curl https://photos.hubris.network/api/sidecar/timeline?count=1 → HTTP 200
|
||||
curl https://photos.hubris.network/api/v1/photos?count=1 → HTTP 200
|
||||
```
|
||||
|
||||
Both endpoints return 200 when tested directly through Caddy with a valid token.
|
||||
|
||||
### Reported issues
|
||||
|
||||
1. **404 on photo grid** — even in private window (no cache interference)
|
||||
2. **Folder tree shows other users' folders** on first load, fixed by refresh
|
||||
|
||||
## 3. Root Cause Analysis
|
||||
|
||||
### Issue 1: 404 on photo grid
|
||||
|
||||
The `sidecar` axios instance (`baseURL: ''`) is missing the **response interceptor** that:
|
||||
- Handles 401 → clears session → redirects to login
|
||||
- Re-throws with meaningful error message
|
||||
|
||||
The `http` instance (for `/api/v1` endpoints) has this interceptor. Without it on `sidecar`:
|
||||
- If the sidecar returns a non-2xx (401, 502 from upstream PP failure, etc.), axios throws a raw error
|
||||
- The TanStack Query error boundary catches it and shows "Request failed with status code <status>"
|
||||
- Very likely the sidecar is returning 401 on some calls (token expired / session not yet established) and the error message might show 404 because Caddy's catch-all returns 404 when a matcher doesn't find a route
|
||||
|
||||
**Hypothesis:** During OIDC login flow, the frontend may make some sidecar calls BEFORE the session is fully established (token loaded into `session.accessToken`). The `sidecar` interceptor checks `session.accessToken` but it might be null. Then the request to `/api/sidecar/timeline` has no auth header → sidecar returns 401 → no response interceptor → raw error.
|
||||
|
||||
**Fix:** Add the same 401 → login redirect interceptor to the `sidecar` instance.
|
||||
|
||||
### Issue 2: Folder tree shows other users
|
||||
|
||||
`listFolders()` calls `http.get('/folders/originals')` which hits PhotoPrism directly. PhotoPrism returns **all folders across the library** regardless of user. The frontend then filters by `userBasePath()` on the result:
|
||||
|
||||
```typescript
|
||||
const bp = userBasePath();
|
||||
if (bp === '') return folders; // On first load, bp might be empty!
|
||||
return folders.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
```
|
||||
|
||||
On first load, `userBasePath()` returns `""` because:
|
||||
1. The session data is loaded asynchronously
|
||||
2. `session.user.BasePath` might not yet be populated when `listFolders` fires
|
||||
3. The TanStack Query cache from a previous session might still have old data
|
||||
|
||||
After a refresh, the session is fully loaded, and `userBasePath()` returns the correct value.
|
||||
|
||||
A secondary issue: the `http` interceptor's 401 handler clears the session on 401. If the session expires during the app's lifetime, all subsequent requests fail with 401.
|
||||
|
||||
## 4. Proposed Approach
|
||||
|
||||
### Phase 1: Fix 404 — add response interceptor to sidecar
|
||||
|
||||
**File:** `web/src/lib/services/photoprism.ts`
|
||||
|
||||
Add the same 401 → login redirect interceptor to `sidecar` as already exists on `http`:
|
||||
|
||||
```typescript
|
||||
sidecar.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401 && browser) {
|
||||
clearSession();
|
||||
const url = err.config?.url ?? '';
|
||||
if (!url.endsWith('/session')) {
|
||||
void goto('/login', { replaceState: true });
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Phase 2: Fix folder tree — sidecar folder proxy
|
||||
|
||||
**File:** `sidecar/handlers_folders.go` (new)
|
||||
|
||||
Add a sidecar endpoint that proxies `/folders/originals` and post-filters by BasePath:
|
||||
|
||||
```
|
||||
GET /api/sidecar/folders → proxies to GET /api/v1/folders/originals
|
||||
→ removes folders not under user's base_path
|
||||
→ returns filtered list
|
||||
```
|
||||
|
||||
This avoids the timing issue entirely by filtering on the server side.
|
||||
|
||||
**Alternative (simpler):** Fix the frontend timing issue by ensuring `listFolders` doesn't fire until the session is ready.
|
||||
|
||||
### Phase 3: Change folder tree in frontend
|
||||
|
||||
**File:** `web/src/lib/services/photoprism.ts`
|
||||
|
||||
Change `listFolders()` to use `sidecar` instance and call `/api/sidecar/folders`:
|
||||
|
||||
```typescript
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
const folders = data.folders ?? [];
|
||||
if (bp === '') return folders;
|
||||
return folders
|
||||
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
.map((f) => ({ ...f, Path: toUserPath(f.Path) }));
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Step-by-step Plan
|
||||
|
||||
### Step 1: Add sidecar response interceptor
|
||||
|
||||
1. Edit `web/src/lib/services/photoprism.ts`
|
||||
2. Add the 401-handling response interceptor to the `sidecar` instance
|
||||
3. The interceptor mirrors the existing `http` response interceptor exactly
|
||||
|
||||
### Step 2: Rebuild frontend
|
||||
|
||||
```bash
|
||||
cd /opt/mule-image/web && npm run build
|
||||
```
|
||||
|
||||
### Step 3: (Optional) Add sidecar folder proxy
|
||||
|
||||
1. New file `sidecar/handlers_folders_proxy.go`
|
||||
2. Handler similar to `handlePhotos` — proxies to `/api/v1/folders/originals`, post-filters by `Path` prefix
|
||||
3. Wire route in `main.go`: `auth.GET("/folders", handleFoldersProxy(pp))`
|
||||
4. Build Docker image, restart sidecar
|
||||
|
||||
### Step 4: Update listFolders to use sidecar
|
||||
|
||||
1. Change `listFolders()` to use `sidecar` instance
|
||||
2. Call `/api/sidecar/folders` instead of `/folders/originals`
|
||||
|
||||
### Step 5: Rebuild + validate
|
||||
|
||||
```bash
|
||||
# Rebuild frontend
|
||||
cd /opt/mule-image/web && npm run build
|
||||
|
||||
# Test through Caddy
|
||||
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1" \
|
||||
-H "X-Auth-Token: <token>" | head -c 200
|
||||
|
||||
# Verify folders
|
||||
curl -s "https://photos.hubris.network/api/sidecar/folders" \
|
||||
-H "X-Auth-Token: <token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps(d[:3],indent=2))"
|
||||
```
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: add sidecar response interceptor + folder proxy" && git push
|
||||
```
|
||||
|
||||
## 6. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `web/src/lib/services/photoprism.ts` | Add response interceptor to sidecar instance; change listFolders URL |
|
||||
| `sidecar/handlers_folders_proxy.go` | **New** — folder proxy handler |
|
||||
| `sidecar/main.go` | Wire folder proxy route |
|
||||
|
||||
## 7. Tests & Validation
|
||||
|
||||
**Manual:**
|
||||
1. Open private window → navigate to photos.hubris.network
|
||||
2. Log in as muli via Authentik OIDC
|
||||
3. Verify photo grid loads without 404
|
||||
4. Verify folder tree shows only muli's folders
|
||||
5. Switch to dtoro account → verify folders/timeline scoped to dtoro
|
||||
|
||||
**API tests:**
|
||||
```bash
|
||||
# Sidecar timeline (no token → 401 redirect)
|
||||
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1"
|
||||
|
||||
# Sidecar folders
|
||||
curl -s "https://photos.hubris.network/api/sidecar/folders"
|
||||
```
|
||||
|
||||
## 8. Risks & Open Questions
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Sidecar returns 401 during OIDC login flow before session is ready | 404 showing instead of graceful redirect | Add response interceptor in Phase 1 |
|
||||
| Folder proxy adds latency | Slower folder tree loading | Minimal — single proxy call, same as PP direct |
|
||||
| `userBasePath()` timing issue in listFolders persists even with sidecar | Folder tree still shows wrong folders on first load | Sidecar filter is server-side → no timing dependency |
|
||||
|
||||
### Open Questions
|
||||
|
||||
- **Q1**: Are there other API calls that bypass the `sidecar` instance and might also be unscoped? (e.g., `listSubjects`, `listGeo`, etc.)
|
||||
- **Q2**: Does the sidecar need a folder proxy, or is the timing fix sufficient? The timing fix (delaying `listFolders` until session is ready) is simpler but fragile.
|
||||
- **Q3**: Could the 404 be from Caddy's catch-all returning 404 when the sidecar isn't reachable? The Caddy fallback timeout for the sidecar might need tuning.
|
||||
327
README.md
327
README.md
@@ -1,227 +1,140 @@
|
||||
# Mulita - Self-Hosted Photo Management Application
|
||||
# mule-image
|
||||
|
||||
A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow. Mulita provides a fast, keyboard-driven interface to browse, organize, tag, and manage your photo library.
|
||||
|
||||
## Features
|
||||
|
||||
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
|
||||
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
|
||||
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and search
|
||||
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
|
||||
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
|
||||
- **Heaps**: Temporary collections for organizing photos
|
||||
- **Tags & Ratings**: Organize with tags, star ratings, and color labels
|
||||
- **Dark Mode**: Photography-optimized dark interface
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Python 3.12 with FastAPI
|
||||
- SQLite with SQLAlchemy (async)
|
||||
- Celery + Redis for background tasks
|
||||
- pyvips for fast thumbnail generation
|
||||
- ExifTool for metadata extraction
|
||||
|
||||
### Frontend
|
||||
- React 18 with TypeScript
|
||||
- Vite for fast development
|
||||
- TanStack Query for data fetching
|
||||
- TanStack Virtual for virtualized scrolling
|
||||
- Tailwind CSS for styling
|
||||
- Zustand for state management
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
|
||||
### Setup (one variable)
|
||||
|
||||
1. Clone the repo:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd muleimage
|
||||
```
|
||||
|
||||
2. Copy the example env file and set **one** variable — the **host**
|
||||
directory that contains your photo library. Whatever you point at
|
||||
will become your library inside Mulita.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# then edit .env and set PHOTO_DIRS:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
```
|
||||
|
||||
3. Start the stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. Open `http://localhost:3000`. On first boot Mulita will:
|
||||
- Mount your `PHOTO_DIRS` at `/photos` inside the container
|
||||
- Auto-create a source root called **Library** pointing at `/photos`
|
||||
- Queue an initial scan, generate thumbnails, and start serving them
|
||||
|
||||
You don't need to touch `mulita.yml` or the API to get started.
|
||||
|
||||
### Configuration knobs
|
||||
|
||||
Everything is environment-driven. `PHOTO_DIRS` is the only required
|
||||
value; the rest have sensible defaults documented in `.env.example`:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|----------------------|---------|----------------------------------------------------|
|
||||
| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. |
|
||||
| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. |
|
||||
| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). |
|
||||
| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). |
|
||||
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. |
|
||||
| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. |
|
||||
| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". |
|
||||
| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
|
||||
|
||||
### Accessing from another machine
|
||||
|
||||
The frontend talks to the backend through its bundled nginx, which
|
||||
proxies `/api/` to the backend on the internal compose network. That
|
||||
means requests are always **same-origin** as the page, so accessing
|
||||
Mulita from another host works without any CORS dance:
|
||||
|
||||
```
|
||||
http://<your-server-ip>:3000
|
||||
```
|
||||
|
||||
If you want to put it behind a reverse proxy at e.g.
|
||||
`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the
|
||||
backend's direct port (`BACKEND_PORT`) also accepts cross-origin
|
||||
requests if anything bypasses the proxy.
|
||||
|
||||
### How libraries are managed
|
||||
|
||||
Mulita is **config-driven**: the host directory you mount via
|
||||
`PHOTO_DIRS` becomes your library, and the backend automatically
|
||||
registers it as a source root on startup. There is no UI for adding
|
||||
or removing source roots — to change what Mulita scans, edit `.env`
|
||||
(or `docker-compose.yml` for multi-mount setups) and restart the
|
||||
stack.
|
||||
|
||||
This keeps the model simple: **the docker mount IS the library**.
|
||||
No two layers, no confusion about which view to use.
|
||||
|
||||
### Changing or adding libraries
|
||||
|
||||
To point at a different library:
|
||||
1. Edit `PHOTO_DIRS` in `.env`
|
||||
2. `docker compose down`
|
||||
3. (Optional, for a clean slate) `docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data`
|
||||
4. `docker compose up -d`
|
||||
|
||||
The new library shows up automatically. Without step 3 the old
|
||||
library's metadata stays in the DB and you'll see a warning at
|
||||
startup that the old source root's path is missing on disk —
|
||||
that's a hint to clean up.
|
||||
|
||||
For multiple libraries, edit `docker-compose.yml` and add additional
|
||||
mount lines:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ${PHOTO_DIRS}:/photos:rw
|
||||
- /Volumes/Archive:/archive:rw # additional library
|
||||
```
|
||||
|
||||
Each mounted directory will need a corresponding source root row in
|
||||
the DB; today that means `POST /api/v1/folders` via curl, or wait
|
||||
for the multi-mount auto-registration that's on the roadmap.
|
||||
|
||||
### Read-only libraries
|
||||
|
||||
The default mount is `:rw` because file operations (rename, move,
|
||||
empty discard pile) need to mutate the filesystem. If you want a
|
||||
strict read-only library — pointing at a network share, an
|
||||
authoritative archive, etc. — flip `:rw` to `:ro` in
|
||||
`docker-compose.yml`. Mulita will keep working for browsing, rating,
|
||||
color labels, picks, heaps, and the (soft) discard flag, but the
|
||||
following will return an OS error:
|
||||
|
||||
- `PATCH /photos/{id}` with a new `filename` (rename)
|
||||
- `POST /photos/move` (bulk move)
|
||||
- `DELETE /discard/empty` (file unlinks)
|
||||
|
||||
**Heads up**: with `:rw`, Mulita has full write access to whatever
|
||||
host directory you mount. Treat the same way you would Lightroom's
|
||||
catalog folder.
|
||||
Self-hosted photo management built on top of [PhotoPrism][pp]. A SvelteKit
|
||||
frontend ([`web/`](web/)) plus a small Go service ([`sidecar/`](sidecar/))
|
||||
fill in the keyboard-driven UI and the file/folder/mark endpoints
|
||||
PhotoPrism's REST API does not expose. PhotoPrism itself handles
|
||||
indexing, originals, thumbnails, and the database; we never re-implement
|
||||
those.
|
||||
|
||||
## Architecture
|
||||
|
||||
The application consists of 5 Docker services:
|
||||
|
||||
- **frontend**: React SPA served by Nginx
|
||||
- **backend**: FastAPI REST API
|
||||
- **worker**: Celery workers for background tasks
|
||||
- **redis**: Message broker for Celery
|
||||
- **db**: SQLite database (file-based)
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` `↑` `↓` | Navigate photos |
|
||||
| `Space` | Quick preview |
|
||||
| `Enter` | Open loupe view |
|
||||
| `P` | Pick photo |
|
||||
| `X` | Reject photo |
|
||||
| `1-5` | Set star rating |
|
||||
| `Tab` | Toggle left sidebar |
|
||||
| `I` | Toggle metadata panel |
|
||||
| `G` | Grid view |
|
||||
| `E` | Loupe view |
|
||||
| `Delete` | Move to trash |
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```text
|
||||
┌──────────────────┐ /api/v1/* ┌──────────────┐
|
||||
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
|
||||
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
|
||||
│ │ ─────────┐ └──────────────┘
|
||||
└──────────────────┘ ▼
|
||||
┌──────────────┐
|
||||
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
|
||||
│ :8000 │ ──▶ originals FS (rename / folders / dups)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
Three compose services — `mariadb`, `photoprism`, `sidecar` — plus the
|
||||
SvelteKit `web/` app served separately. PhotoPrism's port `2342` is
|
||||
**bound to `127.0.0.1` only**; it isn't a user-facing surface. The
|
||||
SvelteKit app is.
|
||||
|
||||
What the sidecar adds on top of PhotoPrism (full list in
|
||||
[`sidecar/README.md`](sidecar/README.md)):
|
||||
|
||||
- Per-photo marks (rating + color) persisted to `mule_sidecar.marks`
|
||||
- File rename + folder create/rename/delete with PhotoPrism reindex
|
||||
- Heap (album) → folder conversion
|
||||
- Perceptual-hash duplicate scan + archive
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
# edit .env: set PHOTO_DIRS to the host path holding your library
|
||||
# rotate PP_ADMIN_PASSWORD, PP_DB_PASSWORD, PP_DB_ROOT_PASSWORD
|
||||
# before any non-local deployment.
|
||||
|
||||
podman-compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.podman.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Then serve the frontend. For local use the simplest path is the Vite
|
||||
dev server:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
# open http://localhost:5173
|
||||
```
|
||||
|
||||
For a static deployment, `npm run build` produces a bundle under
|
||||
`web/build/` that any static file host (nginx, Caddy, GitHub Pages-style)
|
||||
can serve. Reverse-proxy `/api/v1/*` to `http://127.0.0.1:2342` and
|
||||
`/api/sidecar/*` to `http://127.0.0.1:8000`.
|
||||
|
||||
PhotoPrism's own UI is still reachable from the host at
|
||||
`http://127.0.0.1:2342` if you need admin features (user management,
|
||||
settings) — set up an SSH tunnel from your laptop if the server is
|
||||
remote.
|
||||
|
||||
## Configuration
|
||||
|
||||
Source roots are managed by the UI / API (the database owns them). Edit
|
||||
`mulita.yml` to configure operational settings only:
|
||||
All knobs live in [`.env.example`](.env.example). The required ones:
|
||||
|
||||
- Thumbnail sizes, quality, and format
|
||||
- Scanner behaviour (watch, batch size, initial scan)
|
||||
- Performance tuning (concurrency, cache TTLs, DB pool)
|
||||
| Variable | Notes |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `PHOTO_DIRS` | Host path mounted at `/photoprism/originals`. The library. |
|
||||
| `PP_ADMIN_PASSWORD` | First-boot admin password. Rotate. |
|
||||
| `PP_DB_PASSWORD` | MariaDB password for the `photoprism` user. Rotate. |
|
||||
| `PP_DB_ROOT_PASSWORD`| MariaDB root password. Rotate. |
|
||||
| `PP_UID` / `PP_GID` | Host UID/GID that owns `PHOTO_DIRS`. PhotoPrism + sidecar drop to this user inside. |
|
||||
| `PP_PORT` | Loopback host port for PhotoPrism (default `2342`). |
|
||||
| `PP_ORIGINALS_MODE` | `rw` (default) or `ro` — see [Read-only libraries](#read-only-libraries). |
|
||||
| `SIDECAR_PORT` | Loopback host port for the sidecar (default `8000`). |
|
||||
|
||||
## Performance
|
||||
Sidecar-specific env (DB DSN, `USER_BASEPATHS`, etc.) is documented in
|
||||
[`sidecar/README.md`](sidecar/README.md).
|
||||
|
||||
- Handles 100,000+ photos efficiently
|
||||
- Virtual scrolling for smooth timeline navigation
|
||||
- Thumbnail generation at 10+ photos/second
|
||||
- SQLite FTS5 for fast full-text search
|
||||
## Read-only libraries
|
||||
|
||||
## Future Features (Phase 2)
|
||||
The default originals mount is `:rw` because file operations (rename,
|
||||
folder mutations, duplicate archive, heap convert) need to mutate the
|
||||
filesystem. To run against a read-only archive, set
|
||||
`PP_ORIGINALS_MODE=ro` in `.env`. Browsing, marks, ratings, and color
|
||||
labels still work; the following sidecar endpoints return an OS error:
|
||||
|
||||
- AI-powered scene classification
|
||||
- Face detection and clustering
|
||||
- Smart albums
|
||||
- Duplicate detection
|
||||
- Export presets
|
||||
- Multi-user support
|
||||
- `POST /api/sidecar/files/:uid/rename`
|
||||
- `POST /api/sidecar/folders` / `:rel/rename` / `DELETE /:rel`
|
||||
- `POST /api/sidecar/albums/:uid/convert`
|
||||
- `POST /api/sidecar/duplicates/archive`
|
||||
|
||||
## License
|
||||
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
||||
`PP_READONLY` and gates its own backwrite / import paths.
|
||||
|
||||
MIT
|
||||
## Dev iteration loop
|
||||
|
||||
For fast iteration on the sidecar without rebuilding its image on every
|
||||
change, run it as a host process — bring up just `mariadb` and
|
||||
`photoprism` from compose, then build and run the Go binary locally.
|
||||
Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-host-build).
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
.
|
||||
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
|
||||
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
|
||||
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
|
||||
├── .env.example required env vars (copy to .env)
|
||||
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
|
||||
├── pp/ PhotoPrism bind-mounted state (storage, import)
|
||||
├── sidecar/ Go service — see sidecar/README.md
|
||||
└── web/ SvelteKit frontend
|
||||
```
|
||||
|
||||
## GPU video acceleration (optional)
|
||||
|
||||
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
|
||||
[`docker-compose.gpu.yml`](docker-compose.gpu.yml) to hand `/dev/dri/*`
|
||||
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
|
||||
perf win for video thumbnails and HEVC→H.264 transcodes:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
|
||||
Set `PP_FFMPEG_ENCODER=vaapi` in `.env` (default for the overlay). Verify
|
||||
with `docker exec pp-app photoprism show config | grep -i ffmpeg`.
|
||||
|
||||
[pp]: https://photoprism.app/
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Build dependencies
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
# Image processing libraries
|
||||
libvips42 \
|
||||
libvips-dev \
|
||||
# ExifTool for metadata extraction
|
||||
libimage-exiftool-perl \
|
||||
# FFmpeg for video processing
|
||||
ffmpeg \
|
||||
# Git for some Python packages
|
||||
git \
|
||||
# PostgreSQL client (for potential future use)
|
||||
postgresql-client \
|
||||
# Clean up
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /data/thumbs /data/db /data/proxies /app/config
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
Application configuration using Pydantic Settings
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
class ThumbnailSettings(BaseModel):
|
||||
"""Thumbnail generation settings"""
|
||||
small: int = 240
|
||||
medium: int = 640
|
||||
large: int = 1280
|
||||
quality: int = 85
|
||||
format: str = "webp"
|
||||
|
||||
class ScannerSettings(BaseModel):
|
||||
"""File scanner settings"""
|
||||
watch: bool = True
|
||||
initial_scan_on_start: bool = True
|
||||
batch_size: int = 100
|
||||
concurrent_workers: int = 4
|
||||
|
||||
class PerformanceSettings(BaseModel):
|
||||
"""Performance tuning settings"""
|
||||
max_concurrent_thumbnails: int = 10
|
||||
cache_ttl: int = 3600
|
||||
db_pool_size: int = 20
|
||||
db_pool_recycle: int = 3600
|
||||
|
||||
class MulitaConfig(BaseModel):
|
||||
"""Main configuration from YAML file. Source roots and the discard
|
||||
workflow are owned by the database now — only operational settings
|
||||
live here."""
|
||||
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
||||
scanner: ScannerSettings = ScannerSettings()
|
||||
performance: PerformanceSettings = PerformanceSettings()
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="sqlite+aiosqlite:///data/db/mulita.db",
|
||||
env="DATABASE_URL"
|
||||
)
|
||||
|
||||
# Redis
|
||||
redis_url: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
env="REDIS_URL"
|
||||
)
|
||||
|
||||
# Celery
|
||||
celery_broker_url: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
env="CELERY_BROKER_URL"
|
||||
)
|
||||
celery_result_backend: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
env="CELERY_RESULT_BACKEND"
|
||||
)
|
||||
|
||||
# Photo directories
|
||||
photo_dirs: str = Field(
|
||||
default="/photos",
|
||||
env="PHOTO_DIRS"
|
||||
)
|
||||
|
||||
# API settings
|
||||
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
||||
api_port: int = Field(default=8000, env="API_PORT")
|
||||
|
||||
# CORS — comma-separated list of allowed origins, or "*" for any.
|
||||
# Same-origin requests (the normal case behind nginx / vite proxy)
|
||||
# never trip CORS, so this is only for direct browser access from
|
||||
# other origins (LAN IP, reverse proxy, dev tools).
|
||||
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
|
||||
|
||||
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
|
||||
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||
- "*" → wildcard (single-element list ["*"])
|
||||
- "http://a.com,http://b.com" → split + strip
|
||||
Empty entries are dropped.
|
||||
"""
|
||||
raw = (self.allowed_origins or "").strip()
|
||||
if not raw or raw == "*":
|
||||
return ["*"]
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
# App configuration from YAML
|
||||
_config: Optional[MulitaConfig] = None
|
||||
|
||||
@property
|
||||
def config(self) -> MulitaConfig:
|
||||
"""Load configuration from YAML file"""
|
||||
if self._config is None:
|
||||
config_path = Path("/app/config/mulita.yml")
|
||||
if not config_path.exists():
|
||||
config_path = Path("mulita.yml")
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path, "r") as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
self._config = MulitaConfig(**config_data)
|
||||
else:
|
||||
self._config = MulitaConfig()
|
||||
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def thumbnails(self) -> ThumbnailSettings:
|
||||
return self.config.thumbnails
|
||||
|
||||
@property
|
||||
def scanner(self) -> ScannerSettings:
|
||||
return self.config.scanner
|
||||
|
||||
@property
|
||||
def performance(self) -> PerformanceSettings:
|
||||
return self.config.performance
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
@@ -1,89 +0,0 @@
|
||||
"""
|
||||
Database configuration and session management
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import event, text
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create database directory if it doesn't exist
|
||||
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create async engine
|
||||
# SQLite doesn't support pool configuration
|
||||
if "sqlite" in settings.database_url:
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False, # Set to True for SQL debugging
|
||||
connect_args={
|
||||
"check_same_thread": False, # SQLite specific
|
||||
"timeout": 30
|
||||
}
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False, # Set to True for SQL debugging
|
||||
pool_size=settings.performance.db_pool_size,
|
||||
pool_recycle=settings.performance.db_pool_recycle
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency to get database session"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database, create tables if they don't exist"""
|
||||
async with engine.begin() as conn:
|
||||
# Import all models to register them with Base
|
||||
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
|
||||
|
||||
# Create all tables
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Enable WAL mode for SQLite (better concurrency)
|
||||
if "sqlite" in settings.database_url:
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
await conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
await conn.execute(text("PRAGMA cache_size=10000"))
|
||||
await conn.execute(text("PRAGMA temp_store=MEMORY"))
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
async def create_fts_table():
|
||||
"""Create Full-Text Search table for SQLite"""
|
||||
if "sqlite" in settings.database_url:
|
||||
async with engine.begin() as conn:
|
||||
# Create FTS5 virtual table for full-text search
|
||||
await conn.execute(text("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
|
||||
photo_id UNINDEXED,
|
||||
filename,
|
||||
user_title,
|
||||
user_notes,
|
||||
exif_text,
|
||||
tokenize='unicode61'
|
||||
)
|
||||
"""))
|
||||
logger.info("FTS5 table created successfully")
|
||||
@@ -1,107 +0,0 @@
|
||||
"""
|
||||
Mulita - Photo Management Application
|
||||
Main FastAPI application entry point
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.routers import photos, folders, heaps, tags, discard, library
|
||||
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
|
||||
from app.services.cleanup import cleanup_data_integrity
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifecycle"""
|
||||
logger.info("Starting Mulita application...")
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
# First-boot convenience: if there are no source roots in the DB yet,
|
||||
# create one for the default /photos mount so the user sees their
|
||||
# library immediately without configuring anything in the UI.
|
||||
try:
|
||||
await bootstrap_default_source_root()
|
||||
except Exception as e:
|
||||
logger.error(f"Bootstrap source root failed (continuing): {e}")
|
||||
|
||||
# One-shot cleanup of duplicate source_roots / folders left over from
|
||||
# earlier scanner versions that didn't normalize paths. Idempotent.
|
||||
try:
|
||||
await cleanup_data_integrity()
|
||||
except Exception as e:
|
||||
logger.error(f"Startup cleanup failed (continuing): {e}")
|
||||
|
||||
# Start initial scan if configured
|
||||
if settings.scanner.initial_scan_on_start:
|
||||
logger.info("Starting initial library scan...")
|
||||
await start_initial_scan()
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down Mulita application...")
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Mulita Photo Management API",
|
||||
description="Self-hosted photo management application inspired by Lightroom",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configure CORS. The frontend normally talks to the backend through the
|
||||
# nginx (prod) or vite (dev) proxy, so requests are same-origin and never
|
||||
# trip CORS. ALLOWED_ORIGINS in .env controls the fallback for direct
|
||||
# browser access from other origins (LAN IP, reverse proxy under a
|
||||
# different host). Defaults to "*" since this is a single-user homelab
|
||||
# tool; lock it down by setting e.g. ALLOWED_ORIGINS=https://photos.your.tld
|
||||
# in production deployments.
|
||||
_origins = settings.cors_origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
# Wildcard origins can't be combined with credentials per the CORS
|
||||
# spec, so credentials get auto-disabled in that case.
|
||||
allow_credentials=_origins != ["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
|
||||
if os.path.exists("/data/thumbs"):
|
||||
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
|
||||
|
||||
# Include routers
|
||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
||||
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
|
||||
app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"])
|
||||
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"name": "Mulita Photo Management API",
|
||||
"version": "1.0.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for Docker"""
|
||||
return {"status": "healthy"}
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Database models for Mulita
|
||||
"""
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import Folder, SourceRoot
|
||||
from app.models.tags import Tag, PhotoTag
|
||||
from app.models.heaps import Heap, HeapPhoto
|
||||
from app.models.embeddings import Embedding
|
||||
|
||||
__all__ = [
|
||||
'Photo',
|
||||
'Folder',
|
||||
'SourceRoot',
|
||||
'Tag',
|
||||
'PhotoTag',
|
||||
'Heap',
|
||||
'HeapPhoto',
|
||||
'Embedding'
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
"""
|
||||
Embedding model definition (placeholder for AI features)
|
||||
"""
|
||||
from sqlalchemy import Column, String, ForeignKey, LargeBinary
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class Embedding(Base):
|
||||
"""
|
||||
Placeholder table for future AI embeddings (CLIP, face recognition, etc.)
|
||||
"""
|
||||
__tablename__ = 'embeddings'
|
||||
|
||||
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
|
||||
model = Column(String) # e.g., 'clip-vit-b32', 'face-recognition', etc.
|
||||
vector = Column(LargeBinary) # raw float32 bytes for embedding vector
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Folder and SourceRoot model definitions
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class SourceRoot(Base):
|
||||
__tablename__ = 'source_roots'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
path = Column(String, unique=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
added_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
folders = relationship("Folder", back_populates="source_root")
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = 'folders'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
path = Column(String, unique=True, nullable=False)
|
||||
parent_id = Column(String, ForeignKey('folders.id'))
|
||||
source_root_id = Column(String, ForeignKey('source_roots.id'))
|
||||
photo_count = Column(Integer, default=0)
|
||||
last_scanned = Column(DateTime)
|
||||
|
||||
# Relationships
|
||||
source_root = relationship("SourceRoot", back_populates="folders")
|
||||
photos = relationship("Photo", backref="folder")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_folders_path', 'path'),
|
||||
Index('ix_folders_parent_id', 'parent_id'),
|
||||
Index('ix_folders_source_root_id', 'source_root_id'),
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Heap model definitions
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Table, Index
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Association table for many-to-many relationship with additional fields
|
||||
heap_photos = Table(
|
||||
'heap_photos',
|
||||
Base.metadata,
|
||||
Column('heap_id', String, ForeignKey('heaps.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('added_at', DateTime, server_default=func.now()),
|
||||
Column('sort_order', Integer, default=0),
|
||||
Index('ix_heap_photos_heap_id', 'heap_id'),
|
||||
Index('ix_heap_photos_photo_id', 'photo_id'),
|
||||
)
|
||||
|
||||
class Heap(Base):
|
||||
__tablename__ = 'heaps'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
is_active = Column(Boolean, default=False) # For active heap feature
|
||||
|
||||
# Relationships
|
||||
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
|
||||
|
||||
class HeapPhoto:
|
||||
"""Helper class for heap-photo associations (not a table model)"""
|
||||
pass
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
Photo model definition
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Index
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class Photo(Base):
|
||||
__tablename__ = 'photos'
|
||||
|
||||
# Primary key
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# File information
|
||||
filepath = Column(String, unique=True, nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
folder_id = Column(String, ForeignKey('folders.id'))
|
||||
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
|
||||
|
||||
# Media information
|
||||
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'
|
||||
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
file_size = Column(Integer)
|
||||
|
||||
# Timestamps
|
||||
taken_at = Column(DateTime) # from EXIF DateTimeOriginal, fallback to file mtime
|
||||
taken_at_source = Column(String) # 'exif' | 'filesystem' | 'manual'
|
||||
added_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
|
||||
# Discard status. The DB column names stay is_trashed/trashed_at to avoid
|
||||
# a migration; only the Python attribute name reflects the rename.
|
||||
is_discarded = Column('is_trashed', Boolean, default=False)
|
||||
discarded_at = Column('trashed_at', DateTime)
|
||||
|
||||
# Thumbnail paths
|
||||
thumb_small = Column(String) # path to 240px thumb
|
||||
thumb_medium = Column(String) # path to 640px thumb
|
||||
thumb_large = Column(String) # path to 1280px thumb
|
||||
|
||||
# Processing status
|
||||
processing_status = Column(String, default='pending') # 'pending' | 'processing' | 'completed' | 'failed'
|
||||
processing_error = Column(Text)
|
||||
|
||||
# Metadata
|
||||
exif_json = Column(Text) # full EXIF/XMP blob as JSON
|
||||
|
||||
# User-editable fields
|
||||
user_title = Column(String)
|
||||
user_notes = Column(Text)
|
||||
rating = Column(Integer, default=0) # 0-5 stars
|
||||
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||||
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
|
||||
# concept). is_picked was unified with active-heap membership — picking a
|
||||
# photo just means adding it to the active heap. Both DB columns may still
|
||||
# exist on legacy installs but are no longer read or written.
|
||||
|
||||
# Duplicate detection
|
||||
is_duplicate = Column(Boolean, default=False)
|
||||
|
||||
# Live photo support
|
||||
live_photo_video_id = Column(String, ForeignKey('photos.id'))
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index('ix_photos_taken_at', 'taken_at'),
|
||||
Index('ix_photos_folder_id', 'folder_id'),
|
||||
Index('ix_photos_is_trashed', 'is_trashed'),
|
||||
Index('ix_photos_rating', 'rating'),
|
||||
Index('ix_photos_color_label', 'color_label'),
|
||||
Index('ix_photos_media_type', 'media_type'),
|
||||
Index('ix_photos_processing_status', 'processing_status'),
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Tag model definitions
|
||||
"""
|
||||
from sqlalchemy import Column, String, ForeignKey, Table, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Association table for many-to-many relationship
|
||||
photo_tags = Table(
|
||||
'photo_tags',
|
||||
Base.metadata,
|
||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
|
||||
Index('ix_photo_tags_photo_id', 'photo_id'),
|
||||
Index('ix_photo_tags_tag_id', 'tag_id'),
|
||||
)
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = 'tags'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
color = Column(String) # Hex color code for UI display
|
||||
|
||||
# Relationships
|
||||
photos = relationship("Photo", secondary=photo_tags, backref="tags")
|
||||
|
||||
class PhotoTag:
|
||||
"""Helper class for photo-tag associations (not a table model)"""
|
||||
pass
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Discard API router
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
async def list_discarded(db: AsyncSession = Depends(get_db)):
|
||||
"""List discarded photos"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return photos
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
||||
"""Restore photos from the discard pile"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
for photo in photos:
|
||||
photo.is_discarded = False
|
||||
photo.discarded_at = None
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "restored": len(photos)}
|
||||
|
||||
@router.delete("/empty")
|
||||
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
||||
"""Permanently delete all discarded photos and unlink their files from
|
||||
disk. Failures on individual files are logged but don't abort the batch.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos)
|
||||
|
||||
|
||||
@router.delete("")
|
||||
async def delete_discarded(
|
||||
photo_ids: list[str] = Body(..., embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Permanently delete a specific subset of discarded photos. The photos
|
||||
must already be in the discard pile — non-discarded ids are skipped so
|
||||
this can never bypass the soft-delete safety net.
|
||||
"""
|
||||
if not photo_ids:
|
||||
return {"status": "success", "deleted": 0, "file_errors": 0}
|
||||
result = await db.execute(
|
||||
select(Photo).where(
|
||||
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos)
|
||||
|
||||
|
||||
async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict:
|
||||
"""Shared helper: unlink files for the given photos and delete their
|
||||
rows. Per-file errors are counted but don't abort the batch.
|
||||
"""
|
||||
deleted = 0
|
||||
file_errors = 0
|
||||
for photo in photos:
|
||||
try:
|
||||
if photo.filepath and os.path.exists(photo.filepath):
|
||||
os.unlink(photo.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
||||
await db.delete(photo)
|
||||
deleted += 1
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"deleted": deleted,
|
||||
"file_errors": file_errors,
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
"""
|
||||
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
|
||||
in .env → backend bootstrap on startup) — adding or removing one is a
|
||||
docker-compose change. Sub-folders inside a source root can be created,
|
||||
renamed, and deleted from the UI; those changes are mirrored to disk.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Folder, SourceRoot, Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FolderRename(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: str # Folder.id (NOT a SourceRoot id)
|
||||
|
||||
|
||||
def _validate_folder_name(name: str) -> str:
|
||||
"""Trim + sanity-check a folder name. Rejects names that contain a
|
||||
path separator or that resolve to a parent traversal — those would
|
||||
let the user escape the parent directory through this endpoint.
|
||||
"""
|
||||
name = (name or '').strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
||||
if '/' in name or '\\' in name or name in ('.', '..'):
|
||||
raise HTTPException(status_code=400, detail="Invalid folder name")
|
||||
return name
|
||||
|
||||
@router.get("")
|
||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all source folders"""
|
||||
# Get source roots instead of regular folders
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
|
||||
source_roots = result.scalars().all()
|
||||
|
||||
folders_list = []
|
||||
for root in source_roots:
|
||||
# Get photo count for this source root
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.source_root_id == root.id)
|
||||
)
|
||||
folders = folder_result.scalars().all()
|
||||
photo_count = sum(f.photo_count for f in folders)
|
||||
|
||||
folders_list.append({
|
||||
"id": root.id,
|
||||
"name": root.name or os.path.basename(root.path),
|
||||
"path": root.path,
|
||||
"photo_count": photo_count
|
||||
})
|
||||
|
||||
return {"folders": folders_list}
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
"""Recursive folder tree, one root per active SourceRoot. The tree
|
||||
starts at the Folder row matching the SourceRoot.path (the scanner
|
||||
creates one for every walked directory), with the SourceRoot's
|
||||
display name overlaid so the top-level entry reads as "Library"
|
||||
instead of "/photos".
|
||||
|
||||
Returns a list of root nodes; each node has:
|
||||
{ id, name, path, photo_count, children: [...] }
|
||||
|
||||
photo_count is **recursive** — every node reports the total non-
|
||||
discarded photos in its own subtree, so the badge matches what the
|
||||
user sees when they click the row (which also filters recursively).
|
||||
|
||||
The stored Folder.photo_count column is intentionally NOT trusted;
|
||||
the scanner's bookkeeping for that field has historically been
|
||||
wrong (it leaks the global total into whichever folder os.walk
|
||||
visited last). We compute counts here from the photos table.
|
||||
|
||||
Sub-folders that physically belong to the same source root but
|
||||
weren't created on disk (e.g. the / row the scanner sometimes
|
||||
creates as a parent walk) are skipped via path-prefix filtering.
|
||||
"""
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)
|
||||
source_roots = sr_result.scalars().all()
|
||||
|
||||
out = []
|
||||
for sr in source_roots:
|
||||
# Folders physically inside this source root, by path prefix.
|
||||
prefix = os.path.normpath(sr.path).rstrip(os.sep)
|
||||
f_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == sr.id,
|
||||
# Either the folder IS the source root, or it sits beneath it.
|
||||
(Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%'))
|
||||
)
|
||||
)
|
||||
folders = f_result.scalars().all()
|
||||
if not folders:
|
||||
continue
|
||||
|
||||
# Direct (non-recursive) photo counts per folder, computed from
|
||||
# the photos table. Excludes discarded.
|
||||
folder_ids = [f.id for f in folders]
|
||||
direct_counts: dict[str, int] = {}
|
||||
if folder_ids:
|
||||
count_result = await db.execute(
|
||||
select(Photo.folder_id, func.count(Photo.id))
|
||||
.where(
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
Photo.folder_id.in_(folder_ids),
|
||||
)
|
||||
.group_by(Photo.folder_id)
|
||||
)
|
||||
direct_counts = {row[0]: int(row[1]) for row in count_result.all()}
|
||||
|
||||
# Build a path → node map so we can attach children regardless of
|
||||
# parent_id consistency. We populate photo_count with the direct
|
||||
# count first, then accumulate descendants in a post-order pass.
|
||||
nodes = {
|
||||
f.path: {
|
||||
"id": f.id,
|
||||
"name": f.name or os.path.basename(f.path),
|
||||
"path": f.path,
|
||||
"photo_count": direct_counts.get(f.id, 0),
|
||||
"children": [],
|
||||
}
|
||||
for f in folders
|
||||
}
|
||||
|
||||
root_node = None
|
||||
for f in folders:
|
||||
node = nodes[f.path]
|
||||
if f.path == prefix:
|
||||
root_node = node
|
||||
# Override the display name with the source root's label.
|
||||
node["name"] = sr.name or node["name"]
|
||||
continue
|
||||
parent_path = os.path.normpath(os.path.dirname(f.path))
|
||||
parent = nodes.get(parent_path)
|
||||
if parent is not None:
|
||||
parent["children"].append(node)
|
||||
# If parent isn't in the set (orphan from a partial scan), drop
|
||||
# the node — it can't be rendered consistently.
|
||||
|
||||
if root_node is not None:
|
||||
# Sort children alphabetically at every level.
|
||||
def sort_recursive(n):
|
||||
n["children"].sort(key=lambda c: c["name"].lower())
|
||||
for c in n["children"]:
|
||||
sort_recursive(c)
|
||||
sort_recursive(root_node)
|
||||
|
||||
# Post-order: each node's recursive count is its own direct
|
||||
# count plus the sum of every descendant's recursive count.
|
||||
def accumulate(n) -> int:
|
||||
total = n["photo_count"]
|
||||
for c in n["children"]:
|
||||
total += accumulate(c)
|
||||
n["photo_count"] = total
|
||||
return total
|
||||
accumulate(root_node)
|
||||
|
||||
out.append(root_node)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
body: FolderRename,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Rename a folder. Two cases:
|
||||
|
||||
- SourceRoot id → just change the display label. The on-disk path
|
||||
is owned by the docker mount and never moves.
|
||||
- Folder id → rename the directory on disk AND update every
|
||||
descendant Folder.path + Photo.filepath that
|
||||
lived under the old prefix. Refuses to rename
|
||||
the source-root folder itself (= the row that
|
||||
matches the SourceRoot.path) because that would
|
||||
require renaming the docker mount.
|
||||
"""
|
||||
name = _validate_folder_name(body.name)
|
||||
|
||||
# Try SourceRoot first (display-only rename).
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
if source_root:
|
||||
source_root.name = name
|
||||
await db.commit()
|
||||
return {
|
||||
"id": source_root.id,
|
||||
"name": source_root.name,
|
||||
"path": source_root.path,
|
||||
}
|
||||
|
||||
# Otherwise it's a Folder row.
|
||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
# Refuse to rename the bare source root mount through here.
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot rename the source root mount; rename the docker mount instead.",
|
||||
)
|
||||
|
||||
old_path = os.path.normpath(folder.path).rstrip(os.sep)
|
||||
parent_dir = os.path.dirname(old_path)
|
||||
new_path = os.path.join(parent_dir, name)
|
||||
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
|
||||
# Update folder paths: this row + every descendant. SQLite REPLACE
|
||||
# rewrites the prefix; we use the trailing separator on the LIKE
|
||||
# pattern so a folder named "foo" doesn't accidentally match "foobar".
|
||||
await db.execute(
|
||||
sql_update(Folder)
|
||||
.where(Folder.id == folder.id)
|
||||
.values(path=new_path, name=name)
|
||||
)
|
||||
descendant_prefix = old_path + os.sep
|
||||
descendants = await db.execute(
|
||||
select(Folder).where(Folder.path.like(descendant_prefix + '%'))
|
||||
)
|
||||
for d in descendants.scalars().all():
|
||||
d.path = new_path + d.path[len(old_path):]
|
||||
|
||||
# Update every photo whose filepath lives under the old prefix.
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
|
||||
)
|
||||
for p in photos_result.scalars().all():
|
||||
p.filepath = new_path + p.filepath[len(old_path):]
|
||||
# Photos directly inside this folder (not in a subdir) won't match
|
||||
# the descendant_prefix LIKE if their old path was old_path + '/file'
|
||||
# — actually they DO match, since 'oldpath/file' starts with
|
||||
# 'oldpath/'. So the loop above already covers them.
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"id": folder.id,
|
||||
"name": folder.name,
|
||||
"path": folder.path,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new sub-folder under an existing Folder. Mirrors the
|
||||
create to disk so the next scan sees it. Body: { name, parent_id }.
|
||||
parent_id MUST be an existing Folder row id (any descendant of a
|
||||
source root); creating a brand-new top-level mount is a docker
|
||||
operation, not a UI one.
|
||||
"""
|
||||
name = _validate_folder_name(body.name)
|
||||
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(Folder.id == body.parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
|
||||
new_path = os.path.join(parent.path, name)
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||
|
||||
new_folder = Folder(
|
||||
name=name,
|
||||
path=new_path,
|
||||
source_root_id=parent.source_root_id,
|
||||
photo_count=0,
|
||||
)
|
||||
db.add(new_folder)
|
||||
await db.commit()
|
||||
await db.refresh(new_folder)
|
||||
return {
|
||||
"id": new_folder.id,
|
||||
"name": new_folder.name,
|
||||
"path": new_folder.path,
|
||||
"parent_id": parent.id,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
mode: Literal['discard', 'permanent'] = Query('discard'),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a folder. Behavior depends on mode:
|
||||
|
||||
- mode=discard (default): mark every photo whose filepath lives
|
||||
under this folder as is_discarded=true. The folder row, its
|
||||
descendant rows, and the on-disk directory are LEFT INTACT —
|
||||
the user can still recover photos from the discard pile, and
|
||||
a re-scan won't double-import them.
|
||||
|
||||
- mode=permanent: unlink every photo file under this folder,
|
||||
remove the photo + folder rows from the DB, and rmtree the
|
||||
on-disk directory. Irreversible.
|
||||
|
||||
Refuses to delete the bare source-root mount in either mode (deleting
|
||||
the docker mount through the UI would be a footgun).
|
||||
"""
|
||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete the source root mount through the UI",
|
||||
)
|
||||
|
||||
folder_path = os.path.normpath(folder.path).rstrip(os.sep)
|
||||
descendant_prefix = folder_path + os.sep
|
||||
|
||||
# Collect every photo under this folder OR any descendant. We match
|
||||
# by filepath prefix instead of folder_id because that catches photos
|
||||
# in nested subfolders without a recursive folder walk.
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(
|
||||
(Photo.filepath == folder_path)
|
||||
| (Photo.filepath.like(descendant_prefix + '%'))
|
||||
)
|
||||
)
|
||||
photos = photos_result.scalars().all()
|
||||
|
||||
if mode == 'discard':
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
for p in photos:
|
||||
p.is_discarded = True
|
||||
p.discarded_at = now
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": "discard",
|
||||
"discarded": len(photos),
|
||||
}
|
||||
|
||||
# mode == 'permanent'
|
||||
file_errors = 0
|
||||
for p in photos:
|
||||
try:
|
||||
if p.filepath and os.path.exists(p.filepath):
|
||||
os.unlink(p.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||
await db.delete(p)
|
||||
|
||||
# Delete this folder + every descendant Folder row.
|
||||
await db.execute(
|
||||
sql_delete(Folder).where(
|
||||
(Folder.id == folder.id)
|
||||
| (Folder.path.like(descendant_prefix + '%'))
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if os.path.isdir(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||
# Don't raise — DB rows are already gone, leaving an orphan
|
||||
# directory is the lesser evil.
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": "permanent",
|
||||
"deleted_photos": len(photos),
|
||||
"file_errors": file_errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{folder_id}/scan")
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Trigger manual re-scan of source root folder"""
|
||||
from app.tasks.celery import celery_app
|
||||
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
|
||||
source_root = result.scalar_one_or_none()
|
||||
|
||||
if not source_root:
|
||||
raise HTTPException(status_code=404, detail="Source folder not found")
|
||||
|
||||
# Queue scan task using the task name defined in the decorator
|
||||
task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
|
||||
return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}
|
||||
@@ -1,423 +0,0 @@
|
||||
"""
|
||||
Heaps API router
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
from typing import Optional, Literal
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update, insert, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Heap, Photo, Folder
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────
|
||||
|
||||
class HeapCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class HeapUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class HeapPhotosBody(BaseModel):
|
||||
photo_ids: list[str]
|
||||
|
||||
|
||||
class HeapConvertBody(BaseModel):
|
||||
target_id: str # folder id OR source root id
|
||||
mode: Literal['move', 'copy'] = 'move'
|
||||
delete_heap: bool = False
|
||||
# Optional subfolder name to create inside the target. If provided, the
|
||||
# actual destination is target_dir/subfolder_name (created if missing).
|
||||
# Path separators and dot-segments are rejected.
|
||||
subfolder_name: Optional[str] = None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
"""List all heaps with photo counts."""
|
||||
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||
count_subq = (
|
||||
select(
|
||||
heap_photos.c.heap_id,
|
||||
func.count(heap_photos.c.photo_id).label("photo_count"),
|
||||
)
|
||||
.group_by(heap_photos.c.heap_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Heap, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
||||
.order_by(Heap.created_at.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"name": h.name,
|
||||
"is_active": bool(h.is_active),
|
||||
"created_at": h.created_at,
|
||||
"updated_at": h.updated_at,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for h, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new heap."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap = Heap(name=name)
|
||||
db.add(heap)
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
return {
|
||||
"id": heap.id,
|
||||
"name": heap.name,
|
||||
"is_active": bool(heap.is_active),
|
||||
"created_at": heap.created_at,
|
||||
"updated_at": heap.updated_at,
|
||||
"photo_count": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{heap_id}")
|
||||
async def update_heap(
|
||||
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
||||
one heap deactivates all others (single-active invariant)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap.name = name
|
||||
|
||||
if body.is_active is not None:
|
||||
if body.is_active:
|
||||
# Clear active flag on all other heaps in one statement
|
||||
await db.execute(update(Heap).values(is_active=False))
|
||||
heap.is_active = True
|
||||
else:
|
||||
heap.is_active = False
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
return {
|
||||
"id": heap.id,
|
||||
"name": heap.name,
|
||||
"is_active": bool(heap.is_active),
|
||||
"created_at": heap.created_at,
|
||||
"updated_at": heap.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{heap_id}/duplicate", status_code=201)
|
||||
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new heap with the same membership as an existing one. The
|
||||
new heap is named "{original} (copy)" and is never the active target —
|
||||
duplicating shouldn't quietly steal the user's T-key destination.
|
||||
"""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
|
||||
db.add(new_heap)
|
||||
await db.flush() # populate new_heap.id without committing yet
|
||||
|
||||
# Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE
|
||||
# heap_id = :src — INSERT each into the new heap. Done as a single
|
||||
# INSERT...SELECT to avoid round-tripping ids through Python.
|
||||
member_rows = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
photo_ids = [row[0] for row in member_rows.all()]
|
||||
if photo_ids:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids],
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_heap)
|
||||
return {
|
||||
"id": new_heap.id,
|
||||
"name": new_heap.name,
|
||||
"is_active": False,
|
||||
"photo_count": len(photo_ids),
|
||||
"created_at": new_heap.created_at,
|
||||
"updated_at": new_heap.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}", status_code=204)
|
||||
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||
rows in heap_photos cascade-delete."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
await db.delete(heap)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{heap_id}/photo_ids")
|
||||
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Return just the photo ids belonging to a heap. Used by the frontend
|
||||
to maintain a fast client-side membership lookup for the active heap
|
||||
(for the basket affordance on thumbnails) without fetching full photo
|
||||
records."""
|
||||
result = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
@router.post("/{heap_id}/photos")
|
||||
async def add_photos_to_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
||||
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "added": 0}
|
||||
|
||||
# Find which ids are already members so we don't violate the PK.
|
||||
existing = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
heap_photos.c.photo_id.in_(body.photo_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
new_ids = [pid for pid in body.photo_ids if pid not in existing_ids]
|
||||
|
||||
if new_ids:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": heap_id, "photo_id": pid} for pid in new_ids],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
||||
|
||||
|
||||
@router.post("/{heap_id}/convert")
|
||||
async def convert_heap_to_folder(
|
||||
heap_id: str,
|
||||
body: HeapConvertBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Convert a heap into a folder by moving (or copying) every member
|
||||
photo into the target directory. Optionally deletes the heap row at
|
||||
the end.
|
||||
|
||||
target_id may be a Folder id or a SourceRoot id (matches the
|
||||
/photos/move convention so the same dropdown can populate it).
|
||||
"""
|
||||
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = heap_result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
# Resolve target_id → (target_dir, target_folder)
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
||||
)
|
||||
source_root = sr_check.scalar_one_or_none()
|
||||
|
||||
if source_root is not None:
|
||||
parent_dir = source_root.path
|
||||
parent_source_root_id = source_root.id
|
||||
else:
|
||||
folder_check = await db.execute(
|
||||
select(Folder).where(Folder.id == body.target_id)
|
||||
)
|
||||
parent_folder = folder_check.scalar_one_or_none()
|
||||
if parent_folder is None:
|
||||
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||
parent_dir = parent_folder.path
|
||||
parent_source_root_id = parent_folder.source_root_id
|
||||
|
||||
if not os.path.isdir(parent_dir):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Target parent does not exist: {parent_dir}",
|
||||
)
|
||||
|
||||
# Resolve target_dir, creating an optional subfolder if requested.
|
||||
if body.subfolder_name is not None:
|
||||
sub = body.subfolder_name.strip()
|
||||
if not sub:
|
||||
raise HTTPException(status_code=400, detail="Subfolder name cannot be empty")
|
||||
if '/' in sub or '\\' in sub or sub in ('.', '..'):
|
||||
raise HTTPException(status_code=400, detail="Invalid subfolder name")
|
||||
target_dir = os.path.join(parent_dir, sub)
|
||||
if not os.path.exists(target_dir):
|
||||
try:
|
||||
os.makedirs(target_dir)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create subfolder: {e}",
|
||||
)
|
||||
elif not os.path.isdir(target_dir):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{target_dir} exists but is not a directory",
|
||||
)
|
||||
else:
|
||||
target_dir = parent_dir
|
||||
|
||||
# Ensure a Folder row for the target, reusing the scanner helper so
|
||||
# path normalization + dedupe stay consistent.
|
||||
from app.tasks.scan import get_or_create_folder
|
||||
target_folder = await get_or_create_folder(db, target_dir, parent_source_root_id)
|
||||
|
||||
# Fetch the heap's photos via the join table.
|
||||
photo_result = await db.execute(
|
||||
select(Photo)
|
||||
.join(heap_photos, Photo.id == heap_photos.c.photo_id)
|
||||
.where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
photos = photo_result.scalars().all()
|
||||
|
||||
moved = 0
|
||||
copied = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
|
||||
if not os.path.exists(os.path.join(directory, filename)):
|
||||
return filename
|
||||
stem, ext = os.path.splitext(filename)
|
||||
for i in range(1, 100):
|
||||
suffix = '' if i == 1 else f' {i}'
|
||||
candidate = f"{stem} (copy{suffix}){ext}"
|
||||
if not os.path.exists(os.path.join(directory, candidate)):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
for photo in photos:
|
||||
if not os.path.exists(photo.filepath):
|
||||
errors.append({"id": photo.id, "error": "source file missing"})
|
||||
continue
|
||||
|
||||
if body.mode == 'move':
|
||||
if photo.folder_id == target_folder.id:
|
||||
continue # already there
|
||||
new_path = os.path.join(target_dir, photo.filename)
|
||||
if os.path.exists(new_path):
|
||||
errors.append({"id": photo.id, "error": f"name collision: {photo.filename}"})
|
||||
continue
|
||||
try:
|
||||
shutil.move(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
photo.filepath = new_path
|
||||
photo.folder_id = target_folder.id
|
||||
moved += 1
|
||||
else: # copy
|
||||
new_name = _unique_target_name(target_dir, photo.filename)
|
||||
if new_name is None:
|
||||
errors.append({"id": photo.id, "error": "too many name collisions"})
|
||||
continue
|
||||
new_path = os.path.join(target_dir, new_name)
|
||||
try:
|
||||
shutil.copy2(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
new_photo = Photo(
|
||||
filepath=new_path,
|
||||
filename=new_name,
|
||||
folder_id=target_folder.id,
|
||||
file_hash=photo.file_hash,
|
||||
media_type=photo.media_type,
|
||||
original_format=photo.original_format,
|
||||
width=photo.width,
|
||||
height=photo.height,
|
||||
file_size=photo.file_size,
|
||||
taken_at=photo.taken_at,
|
||||
taken_at_source=photo.taken_at_source,
|
||||
user_title=photo.user_title,
|
||||
user_notes=photo.user_notes,
|
||||
rating=photo.rating,
|
||||
color_label=photo.color_label,
|
||||
exif_json=photo.exif_json,
|
||||
is_duplicate=True,
|
||||
processing_status='pending',
|
||||
)
|
||||
db.add(new_photo)
|
||||
copied += 1
|
||||
|
||||
if body.delete_heap:
|
||||
await db.delete(heap)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": body.mode,
|
||||
"moved": moved,
|
||||
"copied": copied,
|
||||
"errors": errors,
|
||||
"heap_deleted": body.delete_heap,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}/photos")
|
||||
async def remove_photos_from_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove photos from a heap. Removing a non-member is a no-op."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "removed": 0}
|
||||
|
||||
res = await db.execute(
|
||||
delete(heap_photos).where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
heap_photos.c.photo_id.in_(body.photo_ids),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "success", "removed": res.rowcount or 0}
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
Library API router for stats and scanning
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Get library statistics + per-section counts. Each section count
|
||||
matches the filter the sidebar applies when you click it, so the
|
||||
sidebar badges and the timeline below them stay in sync.
|
||||
|
||||
- all_photos: non-discarded photos + videos (matches the All
|
||||
Photos section's default filter)
|
||||
- rated: non-discarded with rating >= 1
|
||||
- duplicates: non-discarded with is_duplicate = true
|
||||
- discarded: is_discarded = true
|
||||
- total_size: raw bytes across every row, including discarded
|
||||
"""
|
||||
not_discarded = Photo.is_discarded.is_(False)
|
||||
|
||||
all_photos_count = (
|
||||
await db.execute(select(func.count(Photo.id)).where(not_discarded))
|
||||
).scalar() or 0
|
||||
|
||||
rated_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
duplicates_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.is_duplicate.is_(True)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
discarded_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Legacy split (kept for the existing /stats consumers).
|
||||
photo_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
video_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
|
||||
|
||||
return {
|
||||
"all_photos": all_photos_count,
|
||||
"rated": rated_count,
|
||||
"duplicates": duplicates_count,
|
||||
"discarded": discarded_count,
|
||||
"total_photos": photo_count,
|
||||
"total_videos": video_count,
|
||||
"total_size": size,
|
||||
"total_size_gb": round(size / (1024**3), 2) if size else 0,
|
||||
}
|
||||
|
||||
@router.post("/scan")
|
||||
async def trigger_scan():
|
||||
"""Trigger full library re-scan"""
|
||||
from app.tasks.scan import scan_all_source_roots
|
||||
|
||||
scan_all_source_roots.delay()
|
||||
|
||||
return {"status": "success", "message": "Library scan started"}
|
||||
|
||||
@router.get("/scan/status")
|
||||
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get current scan status"""
|
||||
import redis
|
||||
from app.config import settings
|
||||
|
||||
# Connect to Redis to get scan status
|
||||
r = redis.Redis.from_url(settings.redis_url)
|
||||
|
||||
# Get scan status from Redis (set by worker tasks)
|
||||
is_scanning = r.get('scan:active') == b'true'
|
||||
current_folder = r.get('scan:current_folder')
|
||||
processed_files = int(r.get('scan:processed_files') or 0)
|
||||
total_files = int(r.get('scan:total_files') or 0)
|
||||
errors = r.lrange('scan:errors', 0, -1)
|
||||
|
||||
return {
|
||||
"is_scanning": is_scanning,
|
||||
"current_folder": current_folder.decode() if current_folder else None,
|
||||
"processed_files": processed_files,
|
||||
"total_files": total_files,
|
||||
"errors": [e.decode() for e in errors] if errors else []
|
||||
}
|
||||
@@ -1,921 +0,0 @@
|
||||
"""
|
||||
Photos API router
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo, Folder, Tag
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.tags import photo_tags
|
||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
async def list_photos(
|
||||
q: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
tag_ids: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
rating_min: Optional[int] = Query(None, ge=0, le=5),
|
||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||
color_label: Optional[str] = None,
|
||||
is_discarded: Optional[bool] = False,
|
||||
is_duplicate: Optional[bool] = None,
|
||||
heap_id: Optional[str] = None,
|
||||
sort: str = "taken_at",
|
||||
order: str = "desc",
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(100, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List photos with filters and pagination"""
|
||||
|
||||
# Build query — eager-load tags so the response can include them
|
||||
# without an N+1 round-trip per photo.
|
||||
query = select(Photo).options(selectinload(Photo.tags))
|
||||
|
||||
# Apply filters
|
||||
filters = []
|
||||
|
||||
# Text search (would use FTS5 in production)
|
||||
if q:
|
||||
search_pattern = f"%{q}%"
|
||||
filters.append(
|
||||
or_(
|
||||
Photo.filename.ilike(search_pattern),
|
||||
Photo.user_title.ilike(search_pattern),
|
||||
Photo.user_notes.ilike(search_pattern),
|
||||
Photo.exif_json.ilike(search_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
# Date range
|
||||
if date_from:
|
||||
filters.append(Photo.taken_at >= date_from)
|
||||
if date_to:
|
||||
filters.append(Photo.taken_at <= date_to)
|
||||
|
||||
# Folder filter. The sidebar can pass either a SourceRoot id or a
|
||||
# Folder id; both should include descendants so clicking a parent
|
||||
# folder shows everything under it (Lightroom semantics).
|
||||
if folder_id:
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
)
|
||||
sr_row = sr_check.scalar_one_or_none()
|
||||
|
||||
if sr_row is not None:
|
||||
# Source root → all folders under it (any depth).
|
||||
child_folders = await db.execute(
|
||||
select(Folder.id).where(Folder.source_root_id == folder_id)
|
||||
)
|
||||
child_ids = [row[0] for row in child_folders.all()]
|
||||
if child_ids:
|
||||
filters.append(Photo.folder_id.in_(child_ids))
|
||||
else:
|
||||
filters.append(Photo.id == '__no_match__')
|
||||
else:
|
||||
# Folder id → that folder + every descendant by path prefix.
|
||||
target_check = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
target = target_check.scalar_one_or_none()
|
||||
if target is None:
|
||||
filters.append(Photo.id == '__no_match__')
|
||||
else:
|
||||
target_path = os.path.normpath(target.path).rstrip(os.sep)
|
||||
desc_result = await db.execute(
|
||||
select(Folder.id).where(
|
||||
(Folder.path == target_path)
|
||||
| (Folder.path.like(target_path + os.sep + '%'))
|
||||
)
|
||||
)
|
||||
desc_ids = [row[0] for row in desc_result.all()]
|
||||
filters.append(Photo.folder_id.in_(desc_ids))
|
||||
|
||||
# Media type filter
|
||||
if media_type:
|
||||
types = media_type.split(',')
|
||||
filters.append(Photo.media_type.in_(types))
|
||||
|
||||
# Rating filter
|
||||
if rating_min is not None:
|
||||
filters.append(Photo.rating >= rating_min)
|
||||
if rating_max is not None:
|
||||
filters.append(Photo.rating <= rating_max)
|
||||
|
||||
# Color label filter
|
||||
if color_label:
|
||||
if color_label == 'none':
|
||||
filters.append(Photo.color_label.is_(None))
|
||||
else:
|
||||
filters.append(Photo.color_label == color_label)
|
||||
|
||||
# Discard filter — defaults to hiding discarded photos
|
||||
filters.append(Photo.is_discarded == is_discarded)
|
||||
|
||||
# Duplicate filter — only applied when explicitly set, so the default
|
||||
# view shows everything regardless of duplicate status.
|
||||
if is_duplicate is not None:
|
||||
filters.append(Photo.is_duplicate == is_duplicate)
|
||||
|
||||
# Heap membership filter — restrict to photos that belong to the heap.
|
||||
if heap_id:
|
||||
filters.append(
|
||||
Photo.id.in_(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
)
|
||||
|
||||
# Tag filter — comma-separated tag ids, AND semantics. A photo must
|
||||
# have a row in photo_tags for EVERY listed tag. Implemented as a
|
||||
# single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost
|
||||
# is independent of the number of tags being filtered.
|
||||
if tag_ids:
|
||||
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
|
||||
if tag_id_list:
|
||||
matching_photos = (
|
||||
select(photo_tags.c.photo_id)
|
||||
.where(photo_tags.c.tag_id.in_(tag_id_list))
|
||||
.group_by(photo_tags.c.photo_id)
|
||||
.having(
|
||||
func.count(func.distinct(photo_tags.c.tag_id)) == len(tag_id_list)
|
||||
)
|
||||
)
|
||||
filters.append(Photo.id.in_(matching_photos))
|
||||
|
||||
# Apply all filters
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
|
||||
# Apply sorting. The sort field is whitelisted so a malicious client
|
||||
# can't pass an arbitrary column name (e.g. "filepath" leaks paths or
|
||||
# any other Photo attribute the model exposes).
|
||||
SORT_WHITELIST = {
|
||||
"taken_at": Photo.taken_at,
|
||||
"added_at": Photo.added_at,
|
||||
"filename": Photo.filename,
|
||||
"file_size": Photo.file_size,
|
||||
"rating": Photo.rating,
|
||||
}
|
||||
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
|
||||
if order == "desc":
|
||||
query = query.order_by(sort_column.desc())
|
||||
else:
|
||||
query = query.order_by(sort_column.asc())
|
||||
|
||||
# Count total results
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
# Execute query
|
||||
result = await db.execute(query)
|
||||
photos = result.scalars().all()
|
||||
|
||||
# Convert to response, attaching tags inline so the frontend can group
|
||||
# client-side without a second round-trip.
|
||||
photo_dicts = []
|
||||
for photo in photos:
|
||||
d = PhotoResponse.from_orm(photo).dict()
|
||||
d["tags"] = [
|
||||
{"id": t.id, "name": t.name, "color": t.color}
|
||||
for t in (photo.tags or [])
|
||||
]
|
||||
photo_dicts.append(d)
|
||||
|
||||
return {
|
||||
"photos": photo_dicts,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if total else 0,
|
||||
}
|
||||
|
||||
@router.get("/{photo_id}")
|
||||
async def get_photo(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get single photo with full EXIF and its tags."""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
# Fetch tags via the join table so we don't need to declare a
|
||||
# relationship on the Photo model side.
|
||||
tag_result = await db.execute(
|
||||
select(Tag)
|
||||
.join(photo_tags, Tag.id == photo_tags.c.tag_id)
|
||||
.where(photo_tags.c.photo_id == photo_id)
|
||||
.order_by(Tag.name.asc())
|
||||
)
|
||||
tags = tag_result.scalars().all()
|
||||
|
||||
base = PhotoResponse.from_orm(photo).dict()
|
||||
base["tags"] = [
|
||||
{"id": t.id, "name": t.name, "color": t.color} for t in tags
|
||||
]
|
||||
return base
|
||||
|
||||
|
||||
@router.post("/{photo_id}/tags", status_code=201)
|
||||
async def add_photo_tags(
|
||||
photo_id: str,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
|
||||
Idempotent: re-adding existing members is a no-op."""
|
||||
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
||||
if photo_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
tag_ids = body.get("tag_ids") or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "added": 0}
|
||||
|
||||
existing = await db.execute(
|
||||
select(photo_tags.c.tag_id).where(
|
||||
photo_tags.c.photo_id == photo_id,
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
new_ids = [tid for tid in tag_ids if tid not in existing_ids]
|
||||
|
||||
if new_ids:
|
||||
from sqlalchemy import insert
|
||||
await db.execute(
|
||||
insert(photo_tags),
|
||||
[{"photo_id": photo_id, "tag_id": tid} for tid in new_ids],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "added": len(new_ids)}
|
||||
|
||||
|
||||
@router.delete("/{photo_id}/tags/{tag_id}", status_code=204)
|
||||
async def remove_photo_tag(
|
||||
photo_id: str,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a tag from a photo. Removing a non-member is a no-op."""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
await db.execute(
|
||||
sql_delete(photo_tags).where(
|
||||
photo_tags.c.photo_id == photo_id,
|
||||
photo_tags.c.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
@router.get("/{photo_id}/thumb/{size}")
|
||||
async def get_thumbnail(
|
||||
photo_id: str,
|
||||
size: str,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
|
||||
if size not in ['small', 'medium', 'large']:
|
||||
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
|
||||
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
# Check if thumbnail exists, generate if not
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
thumb_path = f"{thumb_dir}/{size}.webp"
|
||||
|
||||
if not os.path.exists(thumb_path):
|
||||
# Generate thumbnail on demand
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
generate_thumbnails.delay(photo_id)
|
||||
|
||||
# For now, return a placeholder or the original with reduced quality
|
||||
if os.path.exists(photo.filepath):
|
||||
from PIL import Image
|
||||
try:
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
img = Image.open(photo.filepath)
|
||||
|
||||
# Auto-rotate based on EXIF
|
||||
from PIL import ExifTags
|
||||
try:
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
if ExifTags.TAGS[orientation] == 'Orientation':
|
||||
break
|
||||
exif = img._getexif()
|
||||
if exif is not None:
|
||||
orient = exif.get(orientation)
|
||||
if orient == 3:
|
||||
img = img.rotate(180, expand=True)
|
||||
elif orient == 6:
|
||||
img = img.rotate(270, expand=True)
|
||||
elif orient == 8:
|
||||
img = img.rotate(90, expand=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Generate thumbnail size
|
||||
sizes = {'small': 150, 'medium': 400, 'large': 800}
|
||||
target_size = sizes.get(size, 400)
|
||||
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Save as WebP
|
||||
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail: {e}")
|
||||
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
|
||||
|
||||
# Check if we're behind Nginx
|
||||
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
||||
# Use Nginx X-Accel-Redirect for better performance
|
||||
response.headers['X-Accel-Redirect'] = f'/internal_thumbs/{photo_id}/{size}.webp'
|
||||
response.headers['Content-Type'] = 'image/webp'
|
||||
return Response()
|
||||
else:
|
||||
# Direct file serving for development
|
||||
return FileResponse(thumb_path, media_type='image/webp')
|
||||
|
||||
@router.get("/{photo_id}/original")
|
||||
async def get_original(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Serve original file (download for RAW, inline for web-safe formats)"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Pick a media type the browser can render inline for web-safe formats
|
||||
# so the loupe view and <video> tags work without forcing a download.
|
||||
ext = Path(photo.filepath).suffix.lower()
|
||||
inline_types = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
||||
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||
'.webm': 'video/webm', '.mkv': 'video/x-matroska',
|
||||
}
|
||||
media_type = inline_types.get(ext, 'application/octet-stream')
|
||||
|
||||
return FileResponse(
|
||||
photo.filepath,
|
||||
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
|
||||
# Extensions that the browser can decode natively. Anything else (RAW, HEIC,
|
||||
# TIFF) needs the /proxy endpoint to convert to WebP for display.
|
||||
_WEB_SAFE_DISPLAY_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
|
||||
def _generate_proxy_webp(src_path: str, dst_path: str) -> None:
|
||||
"""Decode src_path with the appropriate backend and write a full-res WebP
|
||||
to dst_path. Used by GET /photos/{id}/proxy for RAW/HEIC/TIFF display.
|
||||
|
||||
Conservative: catches per-format failures and falls back to extracting an
|
||||
embedded preview where possible (RAW), so a single broken file never
|
||||
crashes the request.
|
||||
"""
|
||||
from PIL import Image
|
||||
ext = Path(src_path).suffix.lower()
|
||||
|
||||
img = None
|
||||
|
||||
# RAW formats — decode via rawpy at full size
|
||||
raw_exts = {'.cr2', '.cr3', '.nef', '.nrw', '.arw', '.srf',
|
||||
'.raf', '.rw2', '.orf', '.srw', '.pef', '.rwl', '.dng'}
|
||||
if ext in raw_exts:
|
||||
try:
|
||||
import rawpy
|
||||
with rawpy.imread(src_path) as raw:
|
||||
rgb = raw.postprocess(use_camera_wb=True, no_auto_bright=False)
|
||||
img = Image.fromarray(rgb, 'RGB')
|
||||
except Exception as e:
|
||||
logger.warning(f"rawpy decode failed for {src_path}: {e}; trying embedded preview")
|
||||
try:
|
||||
import rawpy
|
||||
with rawpy.imread(src_path) as raw:
|
||||
thumb = raw.extract_thumb()
|
||||
if thumb.format == rawpy.ThumbFormat.JPEG:
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(thumb.data))
|
||||
except Exception as e2:
|
||||
logger.error(f"RAW preview extraction also failed for {src_path}: {e2}")
|
||||
raise HTTPException(status_code=415, detail="Unable to decode RAW file")
|
||||
|
||||
# HEIC/HEIF — pillow-heif registers a PIL plugin
|
||||
elif ext in {'.heic', '.heif'}:
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
register_heif_opener()
|
||||
img = Image.open(src_path)
|
||||
except Exception as e:
|
||||
logger.error(f"HEIC decode failed for {src_path}: {e}")
|
||||
raise HTTPException(status_code=415, detail="Unable to decode HEIC file")
|
||||
|
||||
# TIFF and any other PIL-supported format
|
||||
else:
|
||||
try:
|
||||
img = Image.open(src_path)
|
||||
except Exception as e:
|
||||
logger.error(f"PIL open failed for {src_path}: {e}")
|
||||
raise HTTPException(status_code=415, detail="Unable to decode image")
|
||||
|
||||
# Auto-rotate via EXIF
|
||||
try:
|
||||
from PIL import ImageOps
|
||||
img = ImageOps.exif_transpose(img)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||
img.save(dst_path, 'WEBP', quality=90, method=4)
|
||||
|
||||
|
||||
@router.get("/{photo_id}/proxy")
|
||||
async def get_proxy(
|
||||
photo_id: str,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC,
|
||||
TIFF) so the loupe view can display them inline. Web-safe formats are
|
||||
redirected to /original to avoid pointless transcoding.
|
||||
|
||||
Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the
|
||||
cached file (with optional X-Accel-Redirect for production).
|
||||
"""
|
||||
result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
ext = Path(photo.filepath).suffix.lower()
|
||||
|
||||
# Web-safe formats don't need a proxy — serve the original directly so the
|
||||
# browser uses its native decoder. Saves disk and CPU.
|
||||
if ext in _WEB_SAFE_DISPLAY_EXTS:
|
||||
return FileResponse(
|
||||
photo.filepath,
|
||||
media_type={
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
||||
}[ext],
|
||||
)
|
||||
|
||||
proxy_dir = "/data/proxies"
|
||||
proxy_path = f"{proxy_dir}/{photo_id}.webp"
|
||||
|
||||
if not os.path.exists(proxy_path):
|
||||
try:
|
||||
_generate_proxy_webp(photo.filepath, proxy_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy generation failed for {photo_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Proxy generation failed")
|
||||
|
||||
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
||||
response.headers['X-Accel-Redirect'] = f'/internal_proxies/{photo_id}.webp'
|
||||
response.headers['Content-Type'] = 'image/webp'
|
||||
return Response()
|
||||
|
||||
return FileResponse(proxy_path, media_type='image/webp')
|
||||
|
||||
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
||||
async def update_photo(
|
||||
photo_id: str,
|
||||
update: PhotoUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update photo metadata. If `filename` is included, also rename the
|
||||
file on disk in its current directory (no cross-folder moves through
|
||||
this endpoint).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
update_data = update.dict(exclude_unset=True)
|
||||
|
||||
# Filename rename: validate, rename on disk, then update both filename
|
||||
# and filepath atomically. Done before any other field changes so a
|
||||
# filesystem failure leaves the rest of the row untouched.
|
||||
if 'filename' in update_data:
|
||||
new_name = (update_data.pop('filename') or '').strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="Filename cannot be empty")
|
||||
# Reject path separators and parent traversal — same-directory only.
|
||||
if '/' in new_name or '\\' in new_name or new_name in ('.', '..'):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
if new_name != photo.filename:
|
||||
current_dir = os.path.dirname(photo.filepath)
|
||||
new_path = os.path.join(current_dir, new_name)
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="Source file missing on disk")
|
||||
if os.path.exists(new_path):
|
||||
raise HTTPException(status_code=409, detail="A file with that name already exists")
|
||||
|
||||
try:
|
||||
os.rename(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
|
||||
photo.filename = new_name
|
||||
photo.filepath = new_path
|
||||
|
||||
# Apply remaining updates
|
||||
for field, value in update_data.items():
|
||||
setattr(photo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(photo)
|
||||
|
||||
return PhotoResponse.from_orm(photo)
|
||||
|
||||
@router.delete("/{photo_id}")
|
||||
async def discard_photo(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Soft-discard a photo: sets is_discarded=true. The file stays on disk so
|
||||
restore is just a flag flip. Permanent deletion happens via DELETE
|
||||
/discard/{id} or DELETE /discard/empty.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
photo.is_discarded = True
|
||||
photo.discarded_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "message": "Photo discarded"}
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
photo_ids: list[str]
|
||||
target_id: str # folder id OR source root id
|
||||
|
||||
|
||||
class CopyRequest(BaseModel):
|
||||
photo_ids: list[str]
|
||||
target_id: str # folder id OR source root id
|
||||
|
||||
|
||||
@router.post("/copy")
|
||||
async def copy_photos(
|
||||
body: CopyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Copy photos into a target folder. Same target resolution as /move
|
||||
(folder id or source root id), but uses shutil.copy2 and creates new
|
||||
Photo rows for each copied file. Original photos are unaffected.
|
||||
|
||||
Each new row gets is_duplicate=true so the user can spot the
|
||||
duplicates later. The new file's name is suffixed with " (copy)" if
|
||||
a name collision would otherwise happen, and " (copy 2)", etc., for
|
||||
further conflicts.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
# Resolve target_id → (target_dir, target_folder)
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
||||
)
|
||||
source_root = sr_check.scalar_one_or_none()
|
||||
|
||||
if source_root is not None:
|
||||
target_dir = source_root.path
|
||||
from app.tasks.scan import get_or_create_folder
|
||||
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
|
||||
else:
|
||||
folder_check = await db.execute(
|
||||
select(Folder).where(Folder.id == body.target_id)
|
||||
)
|
||||
target_folder = folder_check.scalar_one_or_none()
|
||||
if target_folder is None:
|
||||
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||
target_dir = target_folder.path
|
||||
|
||||
if not os.path.isdir(target_dir):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Target directory does not exist: {target_dir}",
|
||||
)
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "copied": 0, "errors": []}
|
||||
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(Photo.id.in_(body.photo_ids))
|
||||
)
|
||||
photos_to_copy = photos_result.scalars().all()
|
||||
|
||||
copied = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
|
||||
"""Find a non-colliding filename in `directory` based on `filename`,
|
||||
suffixing " (copy)", " (copy 2)", ... if needed. Gives up after 100
|
||||
attempts."""
|
||||
if not os.path.exists(os.path.join(directory, filename)):
|
||||
return filename
|
||||
stem, ext = os.path.splitext(filename)
|
||||
for i in range(1, 100):
|
||||
candidate = f"{stem} (copy{'' if i == 1 else f' {i}'}){ext}"
|
||||
if not os.path.exists(os.path.join(directory, candidate)):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
for photo in photos_to_copy:
|
||||
if not os.path.exists(photo.filepath):
|
||||
errors.append({"id": photo.id, "error": "source file missing"})
|
||||
continue
|
||||
|
||||
new_name = _unique_target_name(target_dir, photo.filename)
|
||||
if new_name is None:
|
||||
errors.append({"id": photo.id, "error": "too many name collisions"})
|
||||
continue
|
||||
|
||||
new_path = os.path.join(target_dir, new_name)
|
||||
|
||||
try:
|
||||
shutil.copy2(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
|
||||
# Create a new Photo row pointing at the copy. Most metadata is
|
||||
# copied verbatim; the file_hash stays so the duplicate flag does
|
||||
# the right thing across the library.
|
||||
new_photo = Photo(
|
||||
filepath=new_path,
|
||||
filename=new_name,
|
||||
folder_id=target_folder.id,
|
||||
file_hash=photo.file_hash,
|
||||
media_type=photo.media_type,
|
||||
original_format=photo.original_format,
|
||||
width=photo.width,
|
||||
height=photo.height,
|
||||
file_size=photo.file_size,
|
||||
taken_at=photo.taken_at,
|
||||
taken_at_source=photo.taken_at_source,
|
||||
user_title=photo.user_title,
|
||||
user_notes=photo.user_notes,
|
||||
rating=photo.rating,
|
||||
color_label=photo.color_label,
|
||||
exif_json=photo.exif_json,
|
||||
is_duplicate=True,
|
||||
processing_status='pending',
|
||||
)
|
||||
db.add(new_photo)
|
||||
copied += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"copied": copied,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_photos(
|
||||
body: MoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Move photos into a target folder. The target can be either a Folder
|
||||
id or a SourceRoot id (since the LeftSidebar only exposes source roots
|
||||
today). The handler resolves the target to an on-disk directory, calls
|
||||
shutil.move for each photo, and updates photo.filepath + folder_id.
|
||||
|
||||
Per-file failures (target name collision, missing source) are collected
|
||||
and returned in the response so a single bad photo doesn't abort the
|
||||
batch.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
# Resolve target_id → (target_dir, target_folder)
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
||||
)
|
||||
source_root = sr_check.scalar_one_or_none()
|
||||
|
||||
if source_root is not None:
|
||||
target_dir = source_root.path
|
||||
# We need a Folder row to point photo.folder_id at. Reuse the
|
||||
# scanner's get_or_create helper so we don't duplicate the dedupe
|
||||
# / normalization logic.
|
||||
from app.tasks.scan import get_or_create_folder
|
||||
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
|
||||
else:
|
||||
folder_check = await db.execute(
|
||||
select(Folder).where(Folder.id == body.target_id)
|
||||
)
|
||||
target_folder = folder_check.scalar_one_or_none()
|
||||
if target_folder is None:
|
||||
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||
target_dir = target_folder.path
|
||||
|
||||
if not os.path.isdir(target_dir):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Target directory does not exist: {target_dir}",
|
||||
)
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "moved": 0, "errors": []}
|
||||
|
||||
# Fetch the photo rows
|
||||
photos_result = await db.execute(
|
||||
select(Photo).where(Photo.id.in_(body.photo_ids))
|
||||
)
|
||||
photos_to_move = photos_result.scalars().all()
|
||||
|
||||
moved = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
for photo in photos_to_move:
|
||||
# Skip if already in the target folder.
|
||||
if photo.folder_id == target_folder.id:
|
||||
continue
|
||||
|
||||
new_path = os.path.join(target_dir, photo.filename)
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
errors.append({"id": photo.id, "error": "source file missing"})
|
||||
continue
|
||||
if os.path.exists(new_path):
|
||||
errors.append({"id": photo.id, "error": f"name already exists in target: {photo.filename}"})
|
||||
continue
|
||||
|
||||
try:
|
||||
shutil.move(photo.filepath, new_path)
|
||||
except OSError as e:
|
||||
errors.append({"id": photo.id, "error": str(e)})
|
||||
continue
|
||||
|
||||
photo.filepath = new_path
|
||||
photo.folder_id = target_folder.id
|
||||
moved += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"moved": moved,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bulk")
|
||||
async def bulk_action(
|
||||
action: BulkAction,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Perform bulk actions on multiple photos"""
|
||||
# Get photos
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id.in_(action.ids))
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
if not photos:
|
||||
raise HTTPException(status_code=404, detail="No photos found")
|
||||
|
||||
# Perform action based on type
|
||||
if action.action == 'discard':
|
||||
for photo in photos:
|
||||
photo.is_discarded = True
|
||||
photo.discarded_at = datetime.utcnow()
|
||||
elif action.action == 'restore':
|
||||
for photo in photos:
|
||||
photo.is_discarded = False
|
||||
photo.discarded_at = None
|
||||
elif action.action == 'set_rating':
|
||||
for photo in photos:
|
||||
photo.rating = action.value
|
||||
elif action.action == 'set_color':
|
||||
for photo in photos:
|
||||
photo.color_label = action.value
|
||||
elif action.action == 'add_tags':
|
||||
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
|
||||
# rows for every (photo, tag) combination that doesn't already
|
||||
# exist, so the operation is idempotent.
|
||||
tag_ids = action.value or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "added": 0, "message": "No tags supplied"}
|
||||
photo_ids = [p.id for p in photos]
|
||||
existing = await db.execute(
|
||||
select(photo_tags.c.photo_id, photo_tags.c.tag_id).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
existing_pairs = {(row[0], row[1]) for row in existing.all()}
|
||||
new_rows = [
|
||||
{"photo_id": pid, "tag_id": tid}
|
||||
for pid in photo_ids
|
||||
for tid in tag_ids
|
||||
if (pid, tid) not in existing_pairs
|
||||
]
|
||||
if new_rows:
|
||||
from sqlalchemy import insert
|
||||
await db.execute(insert(photo_tags), new_rows)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"added": len(new_rows),
|
||||
"message": f"Added {len(new_rows)} tag link{'s' if len(new_rows) != 1 else ''}",
|
||||
}
|
||||
elif action.action == 'remove_tags':
|
||||
tag_ids = action.value or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "removed": 0, "message": "No tags supplied"}
|
||||
photo_ids = [p.id for p in photos]
|
||||
from sqlalchemy import delete as sql_delete
|
||||
result = await db.execute(
|
||||
sql_delete(photo_tags).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"removed": result.rowcount or 0,
|
||||
"message": f"Removed tag link{'s' if (result.rowcount or 0) != 1 else ''}",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"{action.action} applied to {len(photos)} photos"
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"""
|
||||
Tags API router
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, insert, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Tag
|
||||
from app.models.tags import photo_tags
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
"""List all tags with their photo counts."""
|
||||
count_subq = (
|
||||
select(
|
||||
photo_tags.c.tag_id,
|
||||
func.count(photo_tags.c.photo_id).label("photo_count"),
|
||||
)
|
||||
.group_by(photo_tags.c.tag_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
.order_by(Tag.name.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for tag, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new tag. Names are unique — re-creating an existing name
|
||||
returns the existing row instead of erroring (idempotent for the
|
||||
autocomplete UI flow)."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
|
||||
existing = await db.execute(select(Tag).where(Tag.name == name))
|
||||
found = existing.scalar_one_or_none()
|
||||
if found:
|
||||
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0}
|
||||
|
||||
tag = Tag(name=name, color=body.color)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0}
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
async def update_tag(
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Rename or recolor a tag."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
tag.name = name
|
||||
if body.color is not None:
|
||||
tag.color = body.color or None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color}
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a tag. Photo associations cascade-delete via the FK."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
return None
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
Pydantic schemas for photos
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
class PhotoBase(BaseModel):
|
||||
"""Base photo schema"""
|
||||
filename: str
|
||||
media_type: str
|
||||
original_format: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
file_size: Optional[int] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
taken_at_source: Optional[str] = None
|
||||
user_title: Optional[str] = None
|
||||
user_notes: Optional[str] = None
|
||||
rating: int = 0
|
||||
color_label: Optional[str] = None
|
||||
|
||||
class PhotoResponse(PhotoBase):
|
||||
"""Photo response schema"""
|
||||
id: str
|
||||
filepath: str
|
||||
folder_id: Optional[str] = None
|
||||
file_hash: Optional[str] = None
|
||||
added_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
is_discarded: bool = False
|
||||
discarded_at: Optional[datetime] = None
|
||||
thumb_small: Optional[str] = None
|
||||
thumb_medium: Optional[str] = None
|
||||
thumb_large: Optional[str] = None
|
||||
processing_status: str = 'pending'
|
||||
processing_error: Optional[str] = None
|
||||
exif_json: Optional[str] = None
|
||||
is_duplicate: bool = False
|
||||
live_photo_video_id: Optional[str] = None
|
||||
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
class PhotoUpdate(BaseModel):
|
||||
"""Photo update schema"""
|
||||
filename: Optional[str] = None
|
||||
user_title: Optional[str] = None
|
||||
user_notes: Optional[str] = None
|
||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||
color_label: Optional[str] = None
|
||||
is_discarded: Optional[bool] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
|
||||
class PhotoListResponse(BaseModel):
|
||||
"""Photo list response with pagination"""
|
||||
photos: List[PhotoResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
class BulkAction(BaseModel):
|
||||
"""Bulk action on photos"""
|
||||
ids: List[str]
|
||||
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color'
|
||||
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
||||
@@ -1,163 +0,0 @@
|
||||
"""
|
||||
One-shot data integrity cleanup for source_roots / folders / photos.
|
||||
|
||||
Earlier versions of the scanner stored paths verbatim, so trailing slashes
|
||||
and redundant separators produced duplicate SourceRoot and Folder rows for
|
||||
the same physical directory. The watcher also auto-created source roots
|
||||
when fired with a parent dir. This module merges the duplicates and
|
||||
re-points photos to the canonical folder so the data lines up with the
|
||||
post-fix scanner.
|
||||
|
||||
Idempotent: safe to run on every backend startup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo, Folder, SourceRoot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
async def _dedupe_source_roots(session: AsyncSession) -> int:
|
||||
"""Group source roots by normalized path and merge duplicates. Returns
|
||||
the number of rows deleted."""
|
||||
result = await session.execute(select(SourceRoot))
|
||||
rows = result.scalars().all()
|
||||
|
||||
groups: dict[str, list[SourceRoot]] = {}
|
||||
for sr in rows:
|
||||
norm = _normalize_path(sr.path)
|
||||
groups.setdefault(norm, []).append(sr)
|
||||
|
||||
deleted = 0
|
||||
for norm, srs in groups.items():
|
||||
if len(srs) == 1:
|
||||
# Make sure the canonical row's path is normalized too.
|
||||
if srs[0].path != norm:
|
||||
srs[0].path = norm
|
||||
continue
|
||||
# Pick the canonical row: prefer one with a non-empty name and the
|
||||
# earliest added_at (most likely the original).
|
||||
canonical = sorted(
|
||||
srs,
|
||||
key=lambda s: (not bool(s.name), s.added_at or datetime.max),
|
||||
)[0]
|
||||
canonical.path = norm
|
||||
for sr in srs:
|
||||
if sr.id == canonical.id:
|
||||
continue
|
||||
# Re-point folders that referenced the duplicate root.
|
||||
await session.execute(
|
||||
update(Folder)
|
||||
.where(Folder.source_root_id == sr.id)
|
||||
.values(source_root_id=canonical.id)
|
||||
)
|
||||
await session.delete(sr)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def _dedupe_folders(session: AsyncSession) -> int:
|
||||
"""Group folders by normalized path and merge duplicates. Returns the
|
||||
number of rows deleted."""
|
||||
result = await session.execute(select(Folder))
|
||||
rows = result.scalars().all()
|
||||
|
||||
groups: dict[str, list[Folder]] = {}
|
||||
for f in rows:
|
||||
norm = _normalize_path(f.path)
|
||||
groups.setdefault(norm, []).append(f)
|
||||
|
||||
deleted = 0
|
||||
for norm, folders in groups.items():
|
||||
if len(folders) == 1:
|
||||
if folders[0].path != norm:
|
||||
folders[0].path = norm
|
||||
continue
|
||||
# Canonical = the one with the most photos already attached, then
|
||||
# the lowest-id (deterministic tiebreaker).
|
||||
canonical = sorted(
|
||||
folders,
|
||||
key=lambda f: (-(f.photo_count or 0), f.id),
|
||||
)[0]
|
||||
canonical.path = norm
|
||||
for f in folders:
|
||||
if f.id == canonical.id:
|
||||
continue
|
||||
# Re-point photos to the canonical folder.
|
||||
await session.execute(
|
||||
update(Photo)
|
||||
.where(Photo.folder_id == f.id)
|
||||
.values(folder_id=canonical.id)
|
||||
)
|
||||
await session.delete(f)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def _recompute_folder_counts(session: AsyncSession) -> None:
|
||||
"""Set folder.photo_count to the actual non-discarded photo count."""
|
||||
result = await session.execute(select(Folder))
|
||||
folders = result.scalars().all()
|
||||
for f in folders:
|
||||
count_result = await session.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.folder_id == f.id,
|
||||
Photo.is_discarded == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
f.photo_count = int(count_result.scalar() or 0)
|
||||
|
||||
|
||||
async def _warn_stale_source_roots(session: AsyncSession) -> int:
|
||||
"""Log a warning for any active source root whose path no longer exists
|
||||
on disk. Doesn't delete — a missing path could be a temporarily
|
||||
unmounted drive, and silently dropping user data is worse than
|
||||
surfacing a noisy log line.
|
||||
"""
|
||||
result = await session.execute(select(SourceRoot))
|
||||
rows = result.scalars().all()
|
||||
stale = 0
|
||||
for sr in rows:
|
||||
if not os.path.isdir(sr.path):
|
||||
stale += 1
|
||||
logger.warning(
|
||||
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
|
||||
f"— is the docker mount still in place? "
|
||||
f"(Edit docker-compose.yml or PHOTO_DIRS in .env to fix.)"
|
||||
)
|
||||
return stale
|
||||
|
||||
|
||||
async def cleanup_data_integrity() -> dict:
|
||||
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
||||
transaction. Returns a small summary dict for logging."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
sr_deleted = await _dedupe_source_roots(session)
|
||||
f_deleted = await _dedupe_folders(session)
|
||||
await _recompute_folder_counts(session)
|
||||
stale = await _warn_stale_source_roots(session)
|
||||
await session.commit()
|
||||
summary = {
|
||||
"source_roots_merged": sr_deleted,
|
||||
"folders_merged": f_deleted,
|
||||
"source_roots_stale": stale,
|
||||
}
|
||||
if sr_deleted or f_deleted:
|
||||
logger.info(f"Cleanup merged duplicates: {summary}")
|
||||
return summary
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -1,183 +0,0 @@
|
||||
"""
|
||||
Metadata extraction service using ExifTool
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_exif_datetime(date_str: str) -> Optional[datetime]:
|
||||
"""Parse EXIF datetime string to Python datetime"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
# Common EXIF datetime formats
|
||||
formats = [
|
||||
"%Y:%m:%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y:%m:%d %H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S%z"
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def extract_key_metadata(exif_data: Dict) -> Dict:
|
||||
"""Extract key metadata fields for FTS indexing"""
|
||||
key_fields = []
|
||||
|
||||
# Camera information
|
||||
if 'Make' in exif_data:
|
||||
key_fields.append(exif_data['Make'])
|
||||
if 'Model' in exif_data:
|
||||
key_fields.append(exif_data['Model'])
|
||||
if 'LensModel' in exif_data:
|
||||
key_fields.append(exif_data['LensModel'])
|
||||
|
||||
# Location information
|
||||
if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data:
|
||||
key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}")
|
||||
|
||||
# IPTC/XMP keywords
|
||||
if 'Keywords' in exif_data:
|
||||
if isinstance(exif_data['Keywords'], list):
|
||||
key_fields.extend(exif_data['Keywords'])
|
||||
else:
|
||||
key_fields.append(exif_data['Keywords'])
|
||||
|
||||
# Copyright and creator
|
||||
if 'Copyright' in exif_data:
|
||||
key_fields.append(exif_data['Copyright'])
|
||||
if 'Creator' in exif_data:
|
||||
key_fields.append(exif_data['Creator'])
|
||||
if 'Artist' in exif_data:
|
||||
key_fields.append(exif_data['Artist'])
|
||||
|
||||
return {
|
||||
'exif_text': ' '.join(key_fields),
|
||||
'camera_make': exif_data.get('Make'),
|
||||
'camera_model': exif_data.get('Model'),
|
||||
'lens_model': exif_data.get('LensModel'),
|
||||
'gps_latitude': exif_data.get('GPSLatitude'),
|
||||
'gps_longitude': exif_data.get('GPSLongitude'),
|
||||
}
|
||||
|
||||
@shared_task(name='extract_metadata')
|
||||
def extract_metadata(photo_id: str):
|
||||
"""Extract metadata from a photo using ExifTool"""
|
||||
return asyncio.run(_extract_metadata_async(photo_id))
|
||||
|
||||
async def _extract_metadata_async(photo_id: str):
|
||||
"""Async implementation of metadata extraction"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# Get photo from database
|
||||
result = await session.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
logger.error(f"Photo not found: {photo_id}")
|
||||
return {'status': 'error', 'message': 'Photo not found'}
|
||||
|
||||
# Check if file exists
|
||||
if not Path(photo.filepath).exists():
|
||||
logger.error(f"File not found: {photo.filepath}")
|
||||
return {'status': 'error', 'message': 'File not found'}
|
||||
|
||||
# Run ExifTool to extract metadata
|
||||
cmd = [
|
||||
'exiftool',
|
||||
'-j', # JSON output
|
||||
'-G', # Group names
|
||||
'-s', # Short output format
|
||||
'-All', # All metadata
|
||||
photo.filepath
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"ExifTool error: {result.stderr}")
|
||||
return {'status': 'error', 'message': result.stderr}
|
||||
|
||||
# Parse JSON output
|
||||
metadata = json.loads(result.stdout)
|
||||
if metadata and len(metadata) > 0:
|
||||
exif_data = metadata[0]
|
||||
|
||||
# Store full metadata as JSON
|
||||
photo.exif_json = json.dumps(exif_data)
|
||||
|
||||
# Extract taken_at date
|
||||
date_fields = [
|
||||
'EXIF:DateTimeOriginal',
|
||||
'EXIF:CreateDate',
|
||||
'QuickTime:MediaCreateDate',
|
||||
'EXIF:ModifyDate'
|
||||
]
|
||||
|
||||
for field in date_fields:
|
||||
if field in exif_data:
|
||||
taken_at = parse_exif_datetime(exif_data[field])
|
||||
if taken_at:
|
||||
photo.taken_at = taken_at
|
||||
photo.taken_at_source = 'exif'
|
||||
break
|
||||
|
||||
# Extract dimensions if not already set
|
||||
if not photo.width:
|
||||
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
|
||||
if not photo.height:
|
||||
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
|
||||
|
||||
# Extract and store key metadata for search
|
||||
key_metadata = extract_key_metadata(exif_data)
|
||||
|
||||
# Update FTS table (would be done via trigger in production)
|
||||
# For now, we'll store it in a comment
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Metadata extracted for photo {photo_id}")
|
||||
return {
|
||||
'status': 'success',
|
||||
'photo_id': photo_id,
|
||||
'taken_at': photo.taken_at.isoformat() if photo.taken_at else None
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"ExifTool timeout for {photo.filepath}")
|
||||
return {'status': 'error', 'message': 'ExifTool timeout'}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse ExifTool output: {e}")
|
||||
return {'status': 'error', 'message': 'Invalid ExifTool output'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting metadata for {photo_id}: {e}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Scanner service for initial library scan and one-time bootstrap of the
|
||||
default source root on first boot.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import SourceRoot
|
||||
from app.tasks.scan import scan_all_source_roots
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The single host → container mount path. The compose file mounts whatever
|
||||
# the user set as PHOTO_DIRS at this path.
|
||||
DEFAULT_LIBRARY_PATH = "/photos"
|
||||
DEFAULT_LIBRARY_NAME = "Library"
|
||||
|
||||
|
||||
async def bootstrap_default_source_root() -> None:
|
||||
"""If no source roots exist in the DB, create one pointing at the default
|
||||
library mount. Lets a fresh install pick up photos with zero
|
||||
configuration: the user only needs to set PHOTO_DIRS in .env.
|
||||
"""
|
||||
if not os.path.isdir(DEFAULT_LIBRARY_PATH):
|
||||
logger.warning(
|
||||
f"Default library path {DEFAULT_LIBRARY_PATH} is not mounted; "
|
||||
"set PHOTO_DIRS in .env and recreate the container."
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SourceRoot))
|
||||
if result.scalars().first() is not None:
|
||||
return # Already have at least one source root, leave it alone.
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=DEFAULT_LIBRARY_NAME,
|
||||
path=DEFAULT_LIBRARY_PATH,
|
||||
)
|
||||
session.add(source_root)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME} → "
|
||||
f"{DEFAULT_LIBRARY_PATH}"
|
||||
)
|
||||
|
||||
|
||||
async def start_initial_scan():
|
||||
"""Start the initial library scan.
|
||||
|
||||
NOTE: the folder watcher (watch_folders task) is intentionally NOT
|
||||
dispatched here. It's an infinite loop celery task and every backend
|
||||
restart was queuing a new instance, eventually pinning every worker
|
||||
and starving scan_folder dispatches. Re-enabling it needs a Redis
|
||||
lock or a dedicated long-running container — until then the user
|
||||
triggers scans manually via "Scan all folders".
|
||||
"""
|
||||
try:
|
||||
scan_all_source_roots.delay()
|
||||
logger.info("Initial scan queued successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start initial scan: {e}")
|
||||
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
Celery tasks module
|
||||
"""
|
||||
from app.tasks.celery import celery_app
|
||||
from app.tasks.scan import scan_folder, scan_all_source_roots, watch_folders
|
||||
from app.tasks.thumbs import generate_thumbnails, regenerate_all_thumbnails
|
||||
|
||||
__all__ = [
|
||||
'celery_app',
|
||||
'scan_folder',
|
||||
'scan_all_source_roots',
|
||||
'watch_folders',
|
||||
'generate_thumbnails',
|
||||
'regenerate_all_thumbnails'
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
"""
|
||||
Celery configuration and app initialization
|
||||
"""
|
||||
from celery import Celery
|
||||
from app.config import settings
|
||||
|
||||
# Create Celery app
|
||||
celery_app = Celery(
|
||||
'mulita',
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
include=['app.tasks.scan', 'app.tasks.thumbs']
|
||||
)
|
||||
|
||||
# Configure Celery
|
||||
celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='UTC',
|
||||
enable_utc=True,
|
||||
task_routes={
|
||||
'app.tasks.thumbs.*': {'queue': 'high'},
|
||||
'app.tasks.scan.*': {'queue': 'low'},
|
||||
},
|
||||
task_default_queue='default',
|
||||
task_default_exchange='default',
|
||||
task_default_exchange_type='direct',
|
||||
task_default_routing_key='default',
|
||||
broker_connection_retry_on_startup=True,
|
||||
)
|
||||
@@ -1,439 +0,0 @@
|
||||
"""
|
||||
Celery tasks for scanning folders and indexing photos
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import aiofiles
|
||||
import redis
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo, Folder, SourceRoot
|
||||
from app.config import settings
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
from app.services.metadata import extract_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Redis keys read by GET /api/v1/library/scan/status. The frontend
|
||||
# ScanProgress widget polls that endpoint, so anything we want to surface
|
||||
# in the UI lives here.
|
||||
REDIS_KEY_ACTIVE = 'scan:active'
|
||||
REDIS_KEY_CURRENT_FOLDER = 'scan:current_folder'
|
||||
REDIS_KEY_PROCESSED = 'scan:processed_files'
|
||||
REDIS_KEY_TOTAL = 'scan:total_files'
|
||||
REDIS_KEY_ERRORS = 'scan:errors'
|
||||
MAX_ERROR_ENTRIES = 50 # cap the errors list so a noisy scan doesn't blow Redis
|
||||
|
||||
|
||||
def _get_redis():
|
||||
"""Connect to the broker for progress writes. Returns None on failure
|
||||
so a Redis outage doesn't prevent the scan itself from running."""
|
||||
try:
|
||||
return redis.Redis.from_url(settings.celery_broker_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not reach Redis for scan progress: {e}")
|
||||
return None
|
||||
|
||||
# Supported file extensions
|
||||
PHOTO_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp'}
|
||||
RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw'}
|
||||
HEIC_EXTENSIONS = {'.heic', '.heif'}
|
||||
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv'}
|
||||
|
||||
SUPPORTED_EXTENSIONS = PHOTO_EXTENSIONS | RAW_EXTENSIONS | HEIC_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
def get_media_type(filepath: str) -> str:
|
||||
"""Determine media type from file extension"""
|
||||
ext = Path(filepath).suffix.lower()
|
||||
if ext in PHOTO_EXTENSIONS:
|
||||
return 'photo'
|
||||
elif ext in RAW_EXTENSIONS:
|
||||
return 'raw'
|
||||
elif ext in HEIC_EXTENSIONS:
|
||||
return 'heic'
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
return 'video'
|
||||
return 'unknown'
|
||||
|
||||
async def calculate_file_hash(filepath: str) -> str:
|
||||
"""Calculate SHA-256 hash of a file"""
|
||||
hash_sha256 = hashlib.sha256()
|
||||
try:
|
||||
async with aiofiles.open(filepath, 'rb') as f:
|
||||
while chunk := await f.read(8192):
|
||||
hash_sha256.update(chunk)
|
||||
return hash_sha256.hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {filepath}: {e}")
|
||||
return ""
|
||||
|
||||
@shared_task(bind=True, name='scan_folder')
|
||||
def scan_folder(self, folder_path: str, source_root_id: Optional[str] = None):
|
||||
"""
|
||||
Scan a folder and index all photos/videos
|
||||
"""
|
||||
# Run async function in sync context
|
||||
return asyncio.run(_scan_folder_async(folder_path, source_root_id, self))
|
||||
|
||||
async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], task):
|
||||
"""Async implementation of folder scanning. Writes progress to Redis so
|
||||
GET /api/v1/library/scan/status can surface it to the frontend
|
||||
ScanProgress widget."""
|
||||
logger.info(f"Starting scan of folder: {folder_path}")
|
||||
|
||||
r = _get_redis()
|
||||
|
||||
def progress_set(key: str, value) -> None:
|
||||
if r is None:
|
||||
return
|
||||
try:
|
||||
r.set(key, str(value))
|
||||
except Exception as e:
|
||||
logger.debug(f"scan progress set failed: {e}")
|
||||
|
||||
def progress_push_error(message: str) -> None:
|
||||
if r is None:
|
||||
return
|
||||
try:
|
||||
r.lpush(REDIS_KEY_ERRORS, message)
|
||||
r.ltrim(REDIS_KEY_ERRORS, 0, MAX_ERROR_ENTRIES - 1)
|
||||
except Exception as e:
|
||||
logger.debug(f"scan progress push_error failed: {e}")
|
||||
|
||||
# Mark scan active immediately so the UI starts polling fast.
|
||||
progress_set(REDIS_KEY_ACTIVE, 'true')
|
||||
progress_set(REDIS_KEY_CURRENT_FOLDER, folder_path)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# Get or create source root
|
||||
if not source_root_id:
|
||||
source_root = await get_or_create_source_root(session, folder_path)
|
||||
source_root_id = source_root.id
|
||||
|
||||
# Pre-walk to compute the total file count upfront. Without this
|
||||
# the progress bar would jump every time a new subfolder is
|
||||
# encountered because the running total kept growing.
|
||||
total_files = 0
|
||||
for _root, _dirs, files in os.walk(folder_path):
|
||||
total_files += sum(
|
||||
1 for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||
)
|
||||
progress_set(REDIS_KEY_TOTAL, total_files)
|
||||
progress_set(REDIS_KEY_PROCESSED, 0)
|
||||
|
||||
processed_files = 0
|
||||
errors = []
|
||||
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
# Get or create folder entry
|
||||
folder = await get_or_create_folder(session, root, source_root_id)
|
||||
progress_set(REDIS_KEY_CURRENT_FOLDER, root)
|
||||
|
||||
# Filter supported files
|
||||
supported_files = [f for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS]
|
||||
|
||||
# Process files in batches
|
||||
batch_size = settings.scanner.batch_size
|
||||
for i in range(0, len(supported_files), batch_size):
|
||||
batch = supported_files[i:i + batch_size]
|
||||
# Defer task dispatch until AFTER commit so workers don't
|
||||
# query for rows that aren't visible to other sessions yet.
|
||||
pending_dispatch: list[str] = []
|
||||
|
||||
for filename in batch:
|
||||
filepath = os.path.join(root, filename)
|
||||
|
||||
try:
|
||||
# Check if file already exists in database
|
||||
existing = await session.execute(
|
||||
select(Photo).where(Photo.filepath == filepath)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
logger.debug(f"File already indexed: {filepath}")
|
||||
processed_files += 1
|
||||
progress_set(REDIS_KEY_PROCESSED, processed_files)
|
||||
continue
|
||||
|
||||
# Get file stats
|
||||
stat = os.stat(filepath)
|
||||
|
||||
# Calculate file hash for duplicate detection
|
||||
file_hash = await calculate_file_hash(filepath)
|
||||
|
||||
# Check for duplicate by hash
|
||||
duplicate = await session.execute(
|
||||
select(Photo).where(Photo.file_hash == file_hash)
|
||||
) if file_hash else None
|
||||
|
||||
# Create photo entry
|
||||
photo = Photo(
|
||||
filepath=filepath,
|
||||
filename=filename,
|
||||
folder_id=folder.id,
|
||||
file_hash=file_hash,
|
||||
media_type=get_media_type(filepath),
|
||||
original_format=Path(filepath).suffix.upper()[1:],
|
||||
file_size=stat.st_size,
|
||||
taken_at=datetime.fromtimestamp(stat.st_mtime),
|
||||
taken_at_source='filesystem',
|
||||
is_duplicate=bool(duplicate.scalar_one_or_none() if duplicate else False),
|
||||
processing_status='pending'
|
||||
)
|
||||
|
||||
session.add(photo)
|
||||
await session.flush() # Assign defaults / FK ids
|
||||
|
||||
# Queue dispatch happens after the batch commit
|
||||
# below; otherwise the worker can race the writer
|
||||
# and see "Photo not found".
|
||||
pending_dispatch.append(photo.id)
|
||||
|
||||
processed_files += 1
|
||||
progress_set(REDIS_KEY_PROCESSED, processed_files)
|
||||
|
||||
# Celery internal progress (used by celery tooling)
|
||||
if processed_files % 10 == 0:
|
||||
task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={
|
||||
'current': processed_files,
|
||||
'total': total_files,
|
||||
'folder': root,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file {filepath}: {e}")
|
||||
errors.append({'file': filepath, 'error': str(e)})
|
||||
progress_push_error(f"{filepath}: {e}")
|
||||
continue
|
||||
|
||||
# Commit batch, then queue worker tasks. Dispatch order
|
||||
# matters: commit first so workers can find the rows.
|
||||
await session.commit()
|
||||
|
||||
for photo_id in pending_dispatch:
|
||||
generate_thumbnails.delay(photo_id)
|
||||
extract_metadata.delay(photo_id)
|
||||
|
||||
# Update folder scan timestamp
|
||||
folder.last_scanned = datetime.utcnow()
|
||||
folder.photo_count = processed_files
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Scan complete. Processed {processed_files}/{total_files} files. Errors: {len(errors)}")
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'processed': processed_files,
|
||||
'total': total_files,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scan failed: {e}")
|
||||
progress_push_error(f"scan failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
# Always mark inactive on the way out so a crashed scan doesn't
|
||||
# leave the UI thinking we're still scanning.
|
||||
progress_set(REDIS_KEY_ACTIVE, 'false')
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Canonicalise a filesystem path so we don't get duplicate DB rows for
|
||||
the same physical directory due to trailing slashes, redundant separators,
|
||||
or `.` segments. Symlinks are NOT resolved (we want to keep mount paths
|
||||
intact for cross-machine portability)."""
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
|
||||
"""Get or create a source root entry, matching by normalized path."""
|
||||
from sqlalchemy import select
|
||||
|
||||
norm = _normalize_path(path)
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.path == norm)
|
||||
)
|
||||
source_root = result.scalar_one_or_none()
|
||||
|
||||
if not source_root:
|
||||
source_root = SourceRoot(
|
||||
name=Path(norm).name,
|
||||
path=norm,
|
||||
)
|
||||
session.add(source_root)
|
||||
await session.flush()
|
||||
|
||||
return source_root
|
||||
|
||||
|
||||
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
|
||||
"""Get or create a folder entry, matching by normalized path."""
|
||||
from sqlalchemy import select
|
||||
|
||||
norm = _normalize_path(path)
|
||||
result = await session.execute(
|
||||
select(Folder).where(Folder.path == norm)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
|
||||
if not folder:
|
||||
parent_path = _normalize_path(str(Path(norm).parent))
|
||||
|
||||
if parent_path != norm: # Not the filesystem root
|
||||
parent_result = await session.execute(
|
||||
select(Folder).where(Folder.path == parent_path)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if parent:
|
||||
parent_id = parent.id
|
||||
else:
|
||||
# Recursively create parent
|
||||
parent = await get_or_create_folder(session, parent_path, source_root_id)
|
||||
parent_id = parent.id
|
||||
else:
|
||||
parent_id = None
|
||||
|
||||
folder = Folder(
|
||||
name=Path(norm).name,
|
||||
path=norm,
|
||||
parent_id=parent_id,
|
||||
source_root_id=source_root_id,
|
||||
)
|
||||
session.add(folder)
|
||||
await session.flush()
|
||||
|
||||
return folder
|
||||
|
||||
@shared_task(name='scan_all_source_roots')
|
||||
def scan_all_source_roots():
|
||||
"""Scan every active source root currently registered in the DB."""
|
||||
# Clear stale per-scan progress before queuing new work so the UI sees
|
||||
# a clean slate even if a previous run crashed mid-flight.
|
||||
r = _get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
r.delete(REDIS_KEY_ERRORS)
|
||||
r.set(REDIS_KEY_PROCESSED, 0)
|
||||
r.set(REDIS_KEY_TOTAL, 0)
|
||||
except Exception as e:
|
||||
logger.debug(f"scan_all_source_roots redis reset failed: {e}")
|
||||
|
||||
return asyncio.run(_scan_all_source_roots_async())
|
||||
|
||||
|
||||
async def _scan_all_source_roots_async():
|
||||
"""Read every active SourceRoot from the DB and queue a scan_folder task
|
||||
for each. Source roots whose path no longer exists on disk are skipped
|
||||
with a warning (the cleanup service surfaces those at startup too)."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)
|
||||
source_roots = result.scalars().all()
|
||||
for sr in source_roots:
|
||||
if os.path.exists(sr.path):
|
||||
scan_folder.delay(sr.path, sr.id)
|
||||
else:
|
||||
logger.warning(f"Source root path does not exist: {sr.path}")
|
||||
|
||||
|
||||
@shared_task(name='watch_folders')
|
||||
def watch_folders():
|
||||
"""
|
||||
Watch folders for changes using watchfiles. Long-running task that
|
||||
monitors filesystem events under every active source root.
|
||||
"""
|
||||
from watchfiles import watch
|
||||
|
||||
# Read source roots from the DB instead of the (now-removed) YAML
|
||||
# config. We need both the path and the id so we can dispatch
|
||||
# scan_folder with the source_root_id when an event fires.
|
||||
roots: list[tuple[str, str]] = []
|
||||
try:
|
||||
async def _load_roots():
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)
|
||||
return [
|
||||
(os.path.normpath(sr.path), sr.id)
|
||||
for sr in result.scalars().all()
|
||||
if os.path.exists(sr.path)
|
||||
]
|
||||
roots = asyncio.run(_load_roots())
|
||||
except Exception as e:
|
||||
logger.error(f"watch_folders could not load source roots: {e}")
|
||||
return
|
||||
|
||||
if not roots:
|
||||
logger.warning("No valid source roots to watch")
|
||||
return
|
||||
|
||||
paths = [p for p, _ in roots]
|
||||
logger.info(f"Starting folder watcher for: {paths}")
|
||||
|
||||
def find_source_root_for(path: str) -> Optional[str]:
|
||||
"""Return the source_root id whose path contains `path`, or None."""
|
||||
normalized = os.path.normpath(path)
|
||||
for root_path, root_id in roots:
|
||||
if normalized == root_path or normalized.startswith(root_path + os.sep):
|
||||
return root_id
|
||||
return None
|
||||
|
||||
for changes in watch(*paths):
|
||||
for change_type, filepath in changes:
|
||||
filepath = str(filepath)
|
||||
|
||||
# Check if it's a supported file type
|
||||
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||
continue
|
||||
|
||||
if change_type == 'added' or change_type == 'modified':
|
||||
# Queue scan for the parent folder, with the source_root_id
|
||||
# resolved by ancestor lookup so scan_folder doesn't
|
||||
# auto-create a new SourceRoot for an arbitrary subdir.
|
||||
parent_dir = str(Path(filepath).parent)
|
||||
source_root_id = find_source_root_for(parent_dir)
|
||||
if source_root_id is None:
|
||||
logger.debug(
|
||||
f"watcher event for {filepath}: parent {parent_dir} "
|
||||
f"not under any active source root, ignoring"
|
||||
)
|
||||
continue
|
||||
scan_folder.delay(parent_dir, source_root_id)
|
||||
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
|
||||
elif change_type == 'deleted':
|
||||
# Handle file deletion
|
||||
asyncio.run(handle_file_deletion(filepath))
|
||||
|
||||
async def handle_file_deletion(filepath: str):
|
||||
"""Handle deletion of a file from the filesystem"""
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Photo).where(Photo.filepath == filepath)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if photo:
|
||||
# Mark as missing or delete from database
|
||||
photo.is_discarded = True
|
||||
photo.discarded_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
logger.info(f"Marked photo as discarded: {filepath}")
|
||||
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Celery tasks for thumbnail generation
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
import json
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from PIL import Image
|
||||
import imageio
|
||||
from pillow_heif import register_heif_opener
|
||||
import ffmpeg
|
||||
|
||||
# Try to import optional libraries
|
||||
try:
|
||||
import pyvips
|
||||
PYVIPS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYVIPS_AVAILABLE = False
|
||||
print("pyvips not available, using Pillow for image processing")
|
||||
|
||||
try:
|
||||
import rawpy
|
||||
RAWPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
RAWPY_AVAILABLE = False
|
||||
print("rawpy not available, using exiftool for RAW preview extraction")
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Photo
|
||||
from app.config import settings
|
||||
|
||||
# Register HEIF opener with Pillow
|
||||
register_heif_opener()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thumbnail sizes configuration
|
||||
THUMB_SIZES = {
|
||||
'small': settings.thumbnails.small,
|
||||
'medium': settings.thumbnails.medium,
|
||||
'large': settings.thumbnails.large
|
||||
}
|
||||
|
||||
def get_thumb_path(photo_id: str, size: str) -> str:
|
||||
"""Get the path for a thumbnail file"""
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
|
||||
|
||||
def process_standard_image(filepath: str) -> Image.Image:
|
||||
"""Process standard image formats (JPEG, PNG, etc.)"""
|
||||
return Image.open(filepath)
|
||||
|
||||
def process_raw_image(filepath: str) -> Image.Image:
|
||||
"""Process RAW image formats"""
|
||||
if RAWPY_AVAILABLE:
|
||||
try:
|
||||
with rawpy.imread(filepath) as raw:
|
||||
# Use half_size for faster processing
|
||||
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
|
||||
# Convert numpy array to PIL Image
|
||||
return Image.fromarray(rgb, 'RGB')
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing RAW file {filepath}: {e}")
|
||||
# Try to extract embedded JPEG preview
|
||||
return extract_raw_preview(filepath)
|
||||
else:
|
||||
# Use exiftool to extract embedded preview
|
||||
return extract_raw_preview(filepath)
|
||||
|
||||
def extract_raw_preview(filepath: str) -> Optional[Image.Image]:
|
||||
"""Extract embedded JPEG preview from RAW file"""
|
||||
try:
|
||||
# Use exiftool to extract preview
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
cmd = ['exiftool', '-b', '-PreviewImage', filepath]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
|
||||
if result.returncode == 0 and result.stdout:
|
||||
tmp.write(result.stdout)
|
||||
tmp.flush()
|
||||
return Image.open(tmp.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def process_heic_image(filepath: str) -> Image.Image:
|
||||
"""Process HEIC/HEIF image formats"""
|
||||
try:
|
||||
# Use pillow-heif to open the image
|
||||
img = Image.open(filepath)
|
||||
# Convert to RGB if needed
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
return img
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing HEIC file {filepath}: {e}")
|
||||
raise
|
||||
|
||||
def process_video_thumbnail(filepath: str) -> Image.Image:
|
||||
"""Extract thumbnail from video file"""
|
||||
try:
|
||||
# Get video duration
|
||||
probe = ffmpeg.probe(filepath)
|
||||
duration = float(probe['streams'][0]['duration'])
|
||||
|
||||
# Extract frame at 10% of duration
|
||||
timestamp = duration * 0.1
|
||||
|
||||
# Extract frame using ffmpeg
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
stream = ffmpeg.input(filepath, ss=timestamp)
|
||||
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
|
||||
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
|
||||
|
||||
return Image.open(tmp.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
|
||||
# Create a placeholder thumbnail
|
||||
return create_placeholder_thumbnail('video')
|
||||
|
||||
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
|
||||
"""Create a placeholder thumbnail for failed processing"""
|
||||
# Create a simple gray placeholder
|
||||
img = Image.new('RGB', (640, 480), color=(128, 128, 128))
|
||||
return img
|
||||
|
||||
def auto_rotate_image(image: Image.Image) -> Image.Image:
|
||||
"""Auto-rotate image based on EXIF orientation"""
|
||||
try:
|
||||
# Get EXIF data
|
||||
exif = image._getexif()
|
||||
if exif:
|
||||
orientation = exif.get(274) # Orientation tag
|
||||
|
||||
rotation_map = {
|
||||
3: 180,
|
||||
6: 270, # Note: PIL uses different rotation values than vips
|
||||
8: 90
|
||||
}
|
||||
|
||||
if orientation in rotation_map:
|
||||
image = image.rotate(rotation_map[orientation], expand=True)
|
||||
except:
|
||||
pass # No orientation data available
|
||||
|
||||
return image
|
||||
|
||||
def generate_thumbnail(image: Image.Image, size: int, output_path: str):
|
||||
"""Generate a thumbnail of the specified size"""
|
||||
# Maintain aspect ratio
|
||||
image.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Save as WebP with specified quality
|
||||
image.save(
|
||||
output_path,
|
||||
'WEBP',
|
||||
quality=settings.thumbnails.quality,
|
||||
method=4 # Balance between speed and compression
|
||||
)
|
||||
|
||||
@shared_task(bind=True, name='generate_thumbnails')
|
||||
def generate_thumbnails(self, photo_id: str):
|
||||
"""Generate thumbnails for a photo"""
|
||||
return asyncio.run(_generate_thumbnails_async(photo_id, self))
|
||||
|
||||
async def _generate_thumbnails_async(photo_id: str, task):
|
||||
"""Async implementation of thumbnail generation"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# Get photo from database
|
||||
result = await session.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
logger.error(f"Photo not found: {photo_id}")
|
||||
return {'status': 'error', 'message': 'Photo not found'}
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(photo.filepath):
|
||||
logger.error(f"File not found: {photo.filepath}")
|
||||
photo.processing_status = 'failed'
|
||||
photo.processing_error = 'File not found'
|
||||
await session.commit()
|
||||
return {'status': 'error', 'message': 'File not found'}
|
||||
|
||||
# Update processing status
|
||||
photo.processing_status = 'processing'
|
||||
await session.commit()
|
||||
|
||||
# Load and process the image based on type
|
||||
image = None
|
||||
|
||||
if photo.media_type == 'photo':
|
||||
image = process_standard_image(photo.filepath)
|
||||
elif photo.media_type == 'raw':
|
||||
image = process_raw_image(photo.filepath)
|
||||
elif photo.media_type == 'heic':
|
||||
image = process_heic_image(photo.filepath)
|
||||
elif photo.media_type == 'video':
|
||||
image = process_video_thumbnail(photo.filepath)
|
||||
else:
|
||||
logger.error(f"Unsupported media type: {photo.media_type}")
|
||||
image = create_placeholder_thumbnail(photo.media_type)
|
||||
|
||||
if not image:
|
||||
raise Exception("Failed to process image")
|
||||
|
||||
# Auto-rotate based on EXIF
|
||||
image = auto_rotate_image(image)
|
||||
|
||||
# Store original dimensions
|
||||
photo.width = image.width
|
||||
photo.height = image.height
|
||||
|
||||
# Generate thumbnails for each size
|
||||
for size_name, size_value in THUMB_SIZES.items():
|
||||
thumb_path = get_thumb_path(photo_id, size_name)
|
||||
generate_thumbnail(image, size_value, thumb_path)
|
||||
|
||||
# Update database with thumbnail path
|
||||
setattr(photo, f'thumb_{size_name}', thumb_path)
|
||||
|
||||
# Update progress
|
||||
task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'current_size': size_name, 'photo_id': photo_id}
|
||||
)
|
||||
|
||||
# Update processing status
|
||||
photo.processing_status = 'completed'
|
||||
photo.processing_error = None
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Thumbnails generated for photo {photo_id}")
|
||||
return {'status': 'success', 'photo_id': photo_id}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnails for {photo_id}: {e}")
|
||||
|
||||
# Update error status
|
||||
if photo:
|
||||
photo.processing_status = 'failed'
|
||||
photo.processing_error = str(e)
|
||||
await session.commit()
|
||||
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@shared_task(name='regenerate_all_thumbnails')
|
||||
def regenerate_all_thumbnails():
|
||||
"""Regenerate thumbnails for all photos"""
|
||||
return asyncio.run(_regenerate_all_thumbnails_async())
|
||||
|
||||
async def _regenerate_all_thumbnails_async():
|
||||
"""Async implementation of regenerating all thumbnails"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Get all photos that need thumbnails
|
||||
result = await session.execute(
|
||||
select(Photo).where(
|
||||
Photo.processing_status.in_(['pending', 'failed'])
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
logger.info(f"Regenerating thumbnails for {len(photos)} photos")
|
||||
|
||||
for photo in photos:
|
||||
generate_thumbnails.delay(photo.id)
|
||||
|
||||
return {'status': 'queued', 'count': len(photos)}
|
||||
@@ -1,49 +0,0 @@
|
||||
# Core dependencies
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
aiosqlite==0.19.0
|
||||
alembic==1.13.1
|
||||
|
||||
# Redis and Celery
|
||||
redis==5.0.1
|
||||
celery==5.3.6
|
||||
flower==2.0.1
|
||||
|
||||
# Image processing
|
||||
# pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback
|
||||
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
|
||||
pillow==10.2.0
|
||||
pillow-heif==0.15.0
|
||||
imageio==2.33.1
|
||||
imageio-ffmpeg==0.4.9
|
||||
|
||||
# Video processing
|
||||
ffmpeg-python==0.2.0
|
||||
|
||||
# Metadata extraction
|
||||
pyexiftool==0.5.6
|
||||
|
||||
# File watching
|
||||
watchfiles==0.21.0
|
||||
|
||||
# Utilities
|
||||
pyyaml==6.0.1
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.26.0
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Security and authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
black==23.12.1
|
||||
ruff==0.1.11
|
||||
24
docker-compose.gpu.yml
Normal file
24
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
|
||||
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
|
||||
# ships VA-API-enabled ffmpeg; this file just wires the device + group
|
||||
# membership + encoder selection. Layered in by the deploy script on
|
||||
# hosts where /dev/dri/renderD128 exists.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||
|
||||
services:
|
||||
photoprism:
|
||||
devices:
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128
|
||||
- /dev/dri/card0:/dev/dri/card0
|
||||
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
|
||||
# container user (PP_UID:PP_GID, typically 33:10000) is not in
|
||||
# these groups by default; group_add grants access to the device
|
||||
# nodes without changing the primary user.
|
||||
group_add:
|
||||
- "992"
|
||||
- "44"
|
||||
environment:
|
||||
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
|
||||
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}
|
||||
34
docker-compose.podman.yml
Normal file
34
docker-compose.podman.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Podman-rootless overlay for the PhotoPrism stack.
|
||||
#
|
||||
# Apply alongside the base compose file:
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml \
|
||||
# -f docker-compose.podman.yml \
|
||||
# up -d
|
||||
#
|
||||
# Adds the podman-specific bits that would break a vanilla docker compose run:
|
||||
# - userns_mode: keep-id maps container UID to the invoking host UID, so
|
||||
# PhotoPrism (running as PP_UID:PP_GID inside) can actually read the
|
||||
# bind-mounted originals volume on the host (which is owned by the host
|
||||
# user, not by uid 1000-in-the-container-namespace).
|
||||
# - the explicit security_opt entries on the base file work in podman as-is.
|
||||
|
||||
services:
|
||||
# MariaDB writes to a named volume managed by podman; its in-container
|
||||
# `mysql` user expects to own that volume. keep-id breaks this by mapping
|
||||
# in-container UID 999 to a podman-subuid that doesn't own the volume,
|
||||
# so let mariadb use the default userns mapping (root-in-namespace).
|
||||
mariadb:
|
||||
# No userns_mode override — use podman defaults.
|
||||
init: true
|
||||
|
||||
# PhotoPrism does need keep-id, so its container UID maps back to the
|
||||
# host UID that owns the bind-mounted originals/.
|
||||
photoprism:
|
||||
userns_mode: keep-id
|
||||
|
||||
# Sidecar mutates the originals tree (rename / folder ops / heap
|
||||
# convert / .duplicates archive) — same keep-id mapping so its writes
|
||||
# land as the host user, not as a podman-subuid the host doesn't own.
|
||||
sidecar:
|
||||
userns_mode: keep-id
|
||||
@@ -1,105 +1,209 @@
|
||||
version: '3.8'
|
||||
# Compose stack for the PhotoPrism-backed photo app: mariadb + photoprism +
|
||||
# Go sidecar. The SvelteKit web/ frontend runs separately (Vite in dev,
|
||||
# static build in prod) and proxies /api/v1/* to photoprism and
|
||||
# /api/sidecar/* to the sidecar.
|
||||
#
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-frontend
|
||||
ports:
|
||||
# Host port is configurable via FRONTEND_PORT in .env so multiple
|
||||
# instances / other services on the same host don't collide.
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- mulita-network
|
||||
mariadb:
|
||||
# Fully-qualified for podman (which refuses short names by default).
|
||||
# Docker resolves the same digest.
|
||||
image: docker.io/library/mariadb:11
|
||||
container_name: pp-mariadb
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-backend
|
||||
ports:
|
||||
# Direct backend access on the host is rarely needed (the frontend
|
||||
# talks to it through the nginx /api proxy on the same network),
|
||||
# but it's exposed for debugging / curl. Override with BACKEND_PORT.
|
||||
- "${BACKEND_PORT:-8001}:8000"
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
# The single host → container mount for your photo library. Set
|
||||
# PHOTO_DIRS in .env to your library root. Mounted :rw because file
|
||||
# operations (rename, move, empty discard pile) need to mutate the
|
||||
# filesystem; flip to :ro for a strict read-only library and the
|
||||
# write endpoints will return EROFS.
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
- thumbs_data:/data/thumbs
|
||||
- proxies_data:/data/proxies
|
||||
- db_data:/data/db
|
||||
command:
|
||||
- --innodb-buffer-pool-size=512M
|
||||
- --transaction-isolation=READ-COMMITTED
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --max-connections=512
|
||||
- --innodb-rollback-on-timeout=OFF
|
||||
- --innodb-lock-wait-timeout=120
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
- redis
|
||||
networks:
|
||||
- mulita-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-worker
|
||||
command: celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4}
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
- thumbs_data:/data/thumbs
|
||||
- proxies_data:/data/proxies
|
||||
- db_data:/data/db
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
- CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
- redis
|
||||
- backend
|
||||
networks:
|
||||
- mulita-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mulita-redis
|
||||
# Host port exposed only for local debugging; the backend / worker
|
||||
# reach Redis via the internal mulita-network on its container name.
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
MARIADB_INITDB_SKIP_TZINFO: "1"
|
||||
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
|
||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env}
|
||||
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env}
|
||||
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||
# name on the photoprism-network bridge.
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- mulita-network
|
||||
- pp_mariadb_data:/var/lib/mysql
|
||||
# The init script creates the mule_sidecar database + user that the Go
|
||||
# sidecar service will use in M4. Idempotent; no-op on subsequent boots.
|
||||
# ":Z" is the SELinux private-relabel flag — needed on Fedora/RHEL hosts,
|
||||
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
|
||||
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/bin/mariadb-admin", "ping", "-h", "127.0.0.1", "--silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
networks: [photoprism-network]
|
||||
|
||||
photoprism:
|
||||
image: docker.io/photoprism/photoprism:latest
|
||||
container_name: pp-app
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
# PhotoPrism's container drops to a non-root user via PHOTOPRISM_UID /
|
||||
# PHOTOPRISM_GID. Match the host user that owns ${PHOTO_DIRS} so the
|
||||
# process can read originals (and later write sidecars).
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
- apparmor:unconfined
|
||||
ports:
|
||||
# Loopback only — the SvelteKit web/ app (Vite dev or built bundle)
|
||||
# is the user-facing surface; PhotoPrism's own UI stays off the
|
||||
# public interface. Vite proxies /api/v1/* here, and the host-mode
|
||||
# sidecar reaches PHOTOPRISM_BASE_URL=http://localhost:2342. Admin
|
||||
# access to PP's UI is via SSH tunnel only.
|
||||
- "127.0.0.1:${PP_PORT:-2342}:2342"
|
||||
environment:
|
||||
PHOTOPRISM_ADMIN_USER: ${PP_ADMIN_USER:-admin}
|
||||
PHOTOPRISM_ADMIN_PASSWORD: ${PP_ADMIN_PASSWORD:?set PP_ADMIN_PASSWORD in .env}
|
||||
PHOTOPRISM_AUTH_MODE: ${PP_AUTH_MODE:-password}
|
||||
PHOTOPRISM_SITE_URL: ${PP_SITE_URL:-http://localhost:2342/}
|
||||
PHOTOPRISM_ORIGINALS_LIMIT: ${PP_ORIGINALS_LIMIT:-50000}
|
||||
PHOTOPRISM_HTTP_COMPRESSION: gzip
|
||||
PHOTOPRISM_LOG_LEVEL: ${PP_LOG_LEVEL:-info}
|
||||
# Indexer concurrency. Defaults to NumCPU/2 (= 3 on a 6-core LXC),
|
||||
# but each worker forks TF + ffmpeg + libvips so effective load is
|
||||
# much higher — a fresh index of 1.2k photos on M0 pushed the LXC
|
||||
# load to 50+ and starved sibling containers. Pin to a low value
|
||||
# for shared hosts; raise on dedicated machines.
|
||||
PHOTOPRISM_WORKERS: ${PP_WORKERS:-2}
|
||||
# podman-compose doesn't expand nested ${A:-${B:-…}}, so keep this
|
||||
# one-level. Override both PP_WORKERS and PP_INDEX_WORKERS if you
|
||||
# want them to differ.
|
||||
PHOTOPRISM_INDEX_WORKERS: ${PP_INDEX_WORKERS:-2}
|
||||
# M0 safety: keep originals read-only. Flip to "false" in M2 when the
|
||||
# right-sidebar enables metadata edits and we want EXIF backwrite.
|
||||
PHOTOPRISM_READONLY: ${PP_READONLY:-true}
|
||||
PHOTOPRISM_EXPERIMENTAL: "false"
|
||||
PHOTOPRISM_DISABLE_CHOWN: "true"
|
||||
PHOTOPRISM_DISABLE_WEBDAV: ${PP_DISABLE_WEBDAV:-false}
|
||||
PHOTOPRISM_DISABLE_SETTINGS: "false"
|
||||
PHOTOPRISM_DISABLE_TLS: "true"
|
||||
PHOTOPRISM_DEFAULT_TLS: "false"
|
||||
# AI/vision pipeline back on — per plan we re-introduce TF labels + faces.
|
||||
PHOTOPRISM_TENSORFLOW_OFF: "false"
|
||||
PHOTOPRISM_DETECT_NSFW: "true"
|
||||
PHOTOPRISM_UPLOAD_NSFW: "true"
|
||||
# Database
|
||||
PHOTOPRISM_DATABASE_DRIVER: mysql
|
||||
PHOTOPRISM_DATABASE_SERVER: mariadb:3306
|
||||
PHOTOPRISM_DATABASE_NAME: ${PP_DB_NAME:-photoprism}
|
||||
PHOTOPRISM_DATABASE_USER: ${PP_DB_USER:-photoprism}
|
||||
PHOTOPRISM_DATABASE_PASSWORD: ${PP_DB_PASSWORD}
|
||||
# Sidecars next to originals — read by the migrator at M5.
|
||||
PHOTOPRISM_SIDECAR_PATH: ""
|
||||
PHOTOPRISM_SIDECAR_YAML: "true"
|
||||
# EXIF backwrite — disabled in M0 (READONLY blocks writes anyway).
|
||||
# Override in .env: PP_BACKUP_DATABASE=true.
|
||||
PHOTOPRISM_DISABLE_BACKUPS: "false"
|
||||
PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true}
|
||||
PHOTOPRISM_DISABLE_EXIFTOOL: "false"
|
||||
# OIDC — set in .env when the IdP (Authentik) is wired up.
|
||||
# Empty values keep OIDC dormant; the username/password login still works.
|
||||
# PhotoPrism's CLI flags are --oidc-uri / --oidc-client / --oidc-secret
|
||||
# / --oidc-provider, so the env-var names it actually reads are
|
||||
# PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER (NOT _ISSUER_URL
|
||||
# / _CLIENT_ID / _CLIENT_SECRET / _PROVIDER_NAME — those are silently
|
||||
# ignored, OIDC stays dormant, and `photoprism show config` reports
|
||||
# blank oidc-uri / oidc-client). PHOTOPRISM_OIDC_REDIRECT is a bool
|
||||
# (auto-redirect-from-/library/login), not a URL — PhotoPrism builds
|
||||
# the callback from PHOTOPRISM_SITE_URL.
|
||||
PHOTOPRISM_OIDC_PROVIDER: ${OIDC_PROVIDER_NAME:-${OIDC_PROVIDER:-}}
|
||||
PHOTOPRISM_OIDC_URI: ${OIDC_ISSUER_URL:-${OIDC_URI:-}}
|
||||
PHOTOPRISM_OIDC_CLIENT: ${OIDC_CLIENT_ID:-${OIDC_CLIENT:-}}
|
||||
PHOTOPRISM_OIDC_SECRET: ${OIDC_CLIENT_SECRET:-${OIDC_SECRET:-}}
|
||||
PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||
PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true}
|
||||
PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user}
|
||||
PHOTOPRISM_OIDC_REDIRECT: ${OIDC_REDIRECT:-false}
|
||||
working_dir: /photoprism
|
||||
volumes:
|
||||
# Existing photo library — mounted read-only in M0; flip to :rw in M2
|
||||
# when the right-sidebar starts saving edits. ",Z" relabels for SELinux
|
||||
# on Fedora/RHEL; silent no-op elsewhere.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z"
|
||||
- "./pp/storage:/photoprism/storage:Z"
|
||||
- "./pp/import:/photoprism/import:Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
|
||||
# does not expose (file rename, folder mutations, heap convert, duplicate
|
||||
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
|
||||
# the SvelteKit dev server proxies /api/sidecar/* here.
|
||||
sidecar:
|
||||
build:
|
||||
context: ./sidecar
|
||||
container_name: pp-sidecar
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
photoprism:
|
||||
condition: service_started
|
||||
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
|
||||
# ownership the indexer expects on the bind-mounted originals.
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
ports:
|
||||
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
|
||||
# Behind a reverse proxy in production; never published beyond the
|
||||
# host.
|
||||
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
|
||||
environment:
|
||||
ORIGINALS_ROOT: /photoprism/originals
|
||||
PHOTOPRISM_BASE_URL: http://photoprism:2342
|
||||
# Bind on all interfaces inside the container so the host-side
|
||||
# 127.0.0.1:8000 port mapping can reach the listener. The Go
|
||||
# binary defaults to 127.0.0.1 for the host-mode dev loop.
|
||||
SIDECAR_LISTEN_ADDR: 0.0.0.0
|
||||
SIDECAR_PORT: "8000"
|
||||
SIDECAR_DB_HOST: mariadb
|
||||
SIDECAR_DB_PORT: "3306"
|
||||
SIDECAR_DB_USER: sidecar
|
||||
# Rotate before any non-local deployment. Provisioned by
|
||||
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
||||
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
||||
SIDECAR_DB_NAME: mule_sidecar
|
||||
# Second DB connection for poking PhotoPrism's own schema (only
|
||||
# used by the user-basepath reconciler today). Stays inert if
|
||||
# PP_DB_PASSWORD is empty — the reconciler then silently no-ops.
|
||||
PP_DB_HOST: mariadb
|
||||
PP_DB_PORT: "3306"
|
||||
PP_DB_USER: ${PP_DB_USER:-photoprism}
|
||||
PP_DB_PASSWORD: ${PP_DB_PASSWORD:-}
|
||||
PP_DB_NAME: ${PP_DB_NAME:-photoprism}
|
||||
# Declarative username → originals-relative BasePath mapping.
|
||||
# Format: comma-separated `user:path` pairs. Sidecar applies it
|
||||
# to auth_users on boot and every 60s, and `mkdir -p`s each
|
||||
# target subdirectory so PhotoPrism's ACL filter has somewhere to
|
||||
# point. Leave empty to disable.
|
||||
# USER_BASEPATHS="test:test, alice:family/alice"
|
||||
USER_BASEPATHS: ${USER_BASEPATHS:-}
|
||||
volumes:
|
||||
# Sidecar mutates originals (rename, folder mutations, heap
|
||||
# convert) — always rw regardless of PhotoPrism's mount mode.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:rw,Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
networks:
|
||||
mulita-network:
|
||||
photoprism-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
thumbs_data:
|
||||
proxies_data:
|
||||
db_data:
|
||||
redis_data:
|
||||
pp_mariadb_data:
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f0f0f" />
|
||||
<title>Mulimago</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support for real-time updates
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Thumbnail serving with X-Accel-Redirect
|
||||
location /internal_thumbs/ {
|
||||
internal;
|
||||
alias /data/thumbs/;
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
6069
frontend/package-lock.json
generated
6069
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"name": "mulita-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"@tanstack/react-virtual": "^3.0.1",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"framer-motion": "^10.18.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.4.3",
|
||||
"react-intersection-observer": "^9.5.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Timeline } from './components/timeline/Timeline'
|
||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||
import { RightSidebar } from './components/layout/RightSidebar'
|
||||
import { TopBar } from './components/layout/TopBar'
|
||||
import { ScanProgress } from './components/ScanProgress'
|
||||
import { ToastContainer } from './components/ToastContainer'
|
||||
import { KeyboardHints } from './components/KeyboardHints'
|
||||
import { PreviewView } from './components/preview/PreviewView'
|
||||
import { FilterBar } from './components/filter/FilterBar'
|
||||
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||
import { usePhotoStore } from './store/photoStore'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
||||
|
||||
function App() {
|
||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
|
||||
// Bidirectional sync of filter store with URL query params.
|
||||
useFilterUrlSync()
|
||||
|
||||
// Subscribe to the same photos query the Timeline uses, so the keyboard
|
||||
// "open preview on first photo" path can read from the live cache regardless
|
||||
// of what filter key it's stored under.
|
||||
const { data: allPhotos } = usePhotosQuery()
|
||||
|
||||
// Set up global keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
||||
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
|
||||
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
|
||||
})
|
||||
|
||||
// Auto-show right sidebar when photos are selected — but only in grid mode,
|
||||
// so leaving the preview doesn't fight the user's prior sidebar state.
|
||||
// Lives in an effect (not the render body) to avoid setState-during-render
|
||||
// and the cascading re-renders the audit caught.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'grid') return
|
||||
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
||||
setRightSidebarOpen(true)
|
||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
||||
setRightSidebarOpen(false)
|
||||
}
|
||||
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
|
||||
|
||||
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text">
|
||||
<TopBar />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
leftSidebarOpen ? 'w-64' : 'w-0'
|
||||
} overflow-hidden border-r border-border bg-surface`}
|
||||
>
|
||||
<LeftSidebar />
|
||||
</div>
|
||||
|
||||
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
||||
* right of the left sidebar so the filter row doesn't bleed
|
||||
* across the sidebar. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<FilterBar />
|
||||
<DiscardActionBar />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
showRightSidebar ? 'w-80' : 'w-0'
|
||||
} overflow-hidden border-l border-border bg-surface`}
|
||||
>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating keyboard hints — pinned bottom-center, glassy. Sits
|
||||
* above the timeline and below the toast layer. */}
|
||||
<KeyboardHints />
|
||||
|
||||
{/* Scan Progress Indicator */}
|
||||
<ScanProgress />
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer />
|
||||
|
||||
{/* Preview overlay — covers TopBar when active */}
|
||||
{viewMode === 'preview' && <PreviewView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 821 KiB |
@@ -1,52 +0,0 @@
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
|
||||
export function KeyboardHints() {
|
||||
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
|
||||
// In preview mode the viewer has its own context, so the grid hints
|
||||
// would just be confusing. Hide them.
|
||||
if (viewMode === 'preview') return null
|
||||
|
||||
const hints = selectedCount > 0
|
||||
? [
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'P', action: 'Pick → heap' },
|
||||
{ key: 'X', action: 'Discard' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Esc', action: 'Deselect' },
|
||||
]
|
||||
: [
|
||||
{ key: '↑↓←→', action: 'Navigate' },
|
||||
{ key: 'Click', action: 'Select' },
|
||||
{ key: 'Shift+Click', action: 'Range' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: '/', action: 'Search' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
|
||||
{hints.map((hint, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-surface-offset/80 px-1.5 py-0.5 text-[11px] font-medium text-text">
|
||||
{hint.key}
|
||||
</kbd>
|
||||
<span className="text-xs text-text-muted">{hint.action}</span>
|
||||
{i < hints.length - 1 && (
|
||||
<span className="ml-1 text-text-faint">•</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-text-faint">•</span>
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { library } from '../services/api'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ScanStatus {
|
||||
is_scanning: boolean
|
||||
current_folder?: string
|
||||
processed_files: number
|
||||
total_files: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function ScanProgress() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const wasScanningRef = useRef(false)
|
||||
|
||||
// Poll scan status every 2 seconds when scanning
|
||||
const { data: scanStatus } = useQuery<ScanStatus>({
|
||||
queryKey: ['scan-status'],
|
||||
queryFn: async () => {
|
||||
const response = await library.scanStatus()
|
||||
return response
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
// Poll every 2 seconds if scanning, otherwise every 10 seconds
|
||||
return query.state.data?.is_scanning ? 2000 : 10000
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const isScanning = scanStatus?.is_scanning ?? false
|
||||
|
||||
if (isScanning) {
|
||||
setIsVisible(true)
|
||||
setIsMinimized(false)
|
||||
wasScanningRef.current = true
|
||||
} else if (wasScanningRef.current) {
|
||||
// Just transitioned from scanning → done. THIS is the right moment
|
||||
// to invalidate caches that might have new data: the photos query
|
||||
// (new files indexed), the folder tree (new folders walked), the
|
||||
// heap counts (in case a heap photo got reattached).
|
||||
wasScanningRef.current = false
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||
|
||||
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
|
||||
// Keep showing for 3 seconds after scan completes
|
||||
setTimeout(() => {
|
||||
if (!scanStatus?.is_scanning) {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible, queryClient])
|
||||
|
||||
if (!isVisible || !scanStatus) return null
|
||||
|
||||
const progress = scanStatus.total_files > 0
|
||||
? (scanStatus.processed_files / scanStatus.total_files) * 100
|
||||
: 0
|
||||
|
||||
const isComplete = !scanStatus.is_scanning && scanStatus.processed_files > 0
|
||||
const hasErrors = scanStatus.errors && scanStatus.errors.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed bottom-4 right-4 z-40 overflow-hidden rounded-lg border border-border bg-surface shadow-xl transition-all duration-300',
|
||||
isMinimized ? 'w-12' : 'w-80'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between bg-surface-2 px-3 py-2"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{scanStatus.is_scanning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
) : isComplete && !hasErrors ? (
|
||||
<Check className="h-4 w-4 text-pick" />
|
||||
) : hasErrors ? (
|
||||
<AlertCircle className="h-4 w-4 text-reject" />
|
||||
) : (
|
||||
<FolderOpen className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
{!isMinimized && (
|
||||
<span className="text-sm font-medium text-text">
|
||||
{scanStatus.is_scanning
|
||||
? 'Scanning Folders'
|
||||
: isComplete
|
||||
? 'Scan Complete'
|
||||
: 'Scan Status'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isMinimized && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsVisible(false)
|
||||
}}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!isMinimized && (
|
||||
<div className="p-3">
|
||||
{/* Current folder */}
|
||||
{scanStatus.current_folder && (
|
||||
<div className="mb-2 text-xs text-text-muted">
|
||||
<span className="font-mono">{scanStatus.current_folder}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-2">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full transition-all duration-300',
|
||||
scanStatus.is_scanning
|
||||
? 'bg-primary'
|
||||
: hasErrors
|
||||
? 'bg-reject'
|
||||
: 'bg-pick'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-muted">
|
||||
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'font-medium',
|
||||
scanStatus.is_scanning ? 'text-primary' : hasErrors ? 'text-reject' : 'text-pick'
|
||||
)}>
|
||||
{scanStatus.is_scanning
|
||||
? `${Math.round(progress)}%`
|
||||
: isComplete
|
||||
? 'Done'
|
||||
: 'Idle'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{hasErrors && (
|
||||
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
|
||||
<div className="text-xs text-reject">
|
||||
{scanStatus.errors.slice(0, 3).map((error, i) => (
|
||||
<div key={i} className="truncate">
|
||||
• {error}
|
||||
</div>
|
||||
))}
|
||||
{scanStatus.errors.length > 3 && (
|
||||
<div className="mt-1 text-text-muted">
|
||||
+{scanStatus.errors.length - 3} more errors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
// Global toast state (in production, use Zustand or Context)
|
||||
let toastListeners: ((toasts: Toast[]) => void)[] = []
|
||||
let toastList: Toast[] = []
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string, action?: ToastAction) =>
|
||||
addToast('success', title, message, 5000, action),
|
||||
error: (title: string, message?: string, action?: ToastAction) =>
|
||||
addToast('error', title, message, 5000, action),
|
||||
info: (title: string, message?: string, action?: ToastAction) =>
|
||||
addToast('info', title, message, 5000, action),
|
||||
warning: (title: string, message?: string, action?: ToastAction) =>
|
||||
addToast('warning', title, message, 5000, action),
|
||||
}
|
||||
|
||||
function addToast(
|
||||
type: Toast['type'],
|
||||
title: string,
|
||||
message?: string,
|
||||
duration = 5000,
|
||||
action?: ToastAction
|
||||
) {
|
||||
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6)
|
||||
const newToast: Toast = { id, type, title, message, duration, action }
|
||||
toastList = [...toastList, newToast]
|
||||
toastListeners.forEach(listener => listener(toastList))
|
||||
|
||||
// Auto-remove after duration. Toasts with an action get a longer window
|
||||
// so the user has time to actually click Undo.
|
||||
const removeAfter = action ? Math.max(duration, 8000) : duration
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, removeAfter)
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
toastList = toastList.filter(t => t.id !== id)
|
||||
toastListeners.forEach(listener => listener(toastList))
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (newToasts: Toast[]) => setToasts(newToasts)
|
||||
toastListeners.push(listener)
|
||||
return () => {
|
||||
toastListeners = toastListeners.filter(l => l !== listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="h-5 w-5 text-pick" />,
|
||||
error: <XCircle className="h-5 w-5 text-reject" />,
|
||||
info: <Info className="h-5 w-5 text-primary" />,
|
||||
warning: <AlertCircle className="h-5 w-5 text-star" />,
|
||||
}
|
||||
|
||||
const colors = {
|
||||
success: 'border-pick bg-pick/10',
|
||||
error: 'border-reject bg-reject/10',
|
||||
info: 'border-primary bg-primary/10',
|
||||
warning: 'border-star bg-star/10',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={clsx(
|
||||
'pointer-events-auto flex items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-sm transition-all duration-300',
|
||||
'animate-slide-up',
|
||||
colors[toast.type]
|
||||
)}
|
||||
style={{ minWidth: '300px', maxWidth: '400px' }}
|
||||
>
|
||||
{icons[toast.type]}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-text">{toast.title}</div>
|
||||
{toast.message && (
|
||||
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
|
||||
)}
|
||||
</div>
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.action!.onClick()
|
||||
removeToast(toast.id)
|
||||
}}
|
||||
className="pointer-events-auto self-center rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-text hover:bg-surface-2"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: React.ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** When true, the confirm button uses the destructive accent. */
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay
|
||||
* pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmDialogProps) {
|
||||
// Esc to close.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative z-10 w-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
|
||||
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
|
||||
<div className="mb-4 text-sm text-text-muted">{message}</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1.5 text-sm font-medium text-white',
|
||||
destructive
|
||||
? 'bg-reject hover:bg-reject/80'
|
||||
: 'bg-primary hover:bg-primary/80'
|
||||
)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Trash2, Archive } from 'lucide-react'
|
||||
|
||||
interface DeleteFolderDialogProps {
|
||||
isOpen: boolean
|
||||
folderName: string
|
||||
/** Number of photos under this folder, including descendants. Surfaced
|
||||
* in the dialog copy so the user understands the blast radius. */
|
||||
photoCount?: number
|
||||
onClose: () => void
|
||||
/** Called with the chosen mode when the user confirms. */
|
||||
onConfirm: (mode: 'discard' | 'permanent') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-mode folder delete dialog:
|
||||
*
|
||||
* - Move to discard pile (default, soft, recoverable)
|
||||
* - Permanently delete (destructive, irreversible)
|
||||
*
|
||||
* The user picks a mode via the radio cards then clicks Delete. Esc /
|
||||
* backdrop click cancels.
|
||||
*/
|
||||
export function DeleteFolderDialog({
|
||||
isOpen,
|
||||
folderName,
|
||||
photoCount,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteFolderDialogProps) {
|
||||
const [mode, setMode] = useState<'discard' | 'permanent'>('discard')
|
||||
|
||||
// Reset mode when re-opening so the safe option is always the default.
|
||||
useEffect(() => {
|
||||
if (isOpen) setMode('discard')
|
||||
}, [isOpen])
|
||||
|
||||
// Esc to close.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const photoBlurb =
|
||||
photoCount === undefined
|
||||
? 'photos in this folder'
|
||||
: photoCount === 0
|
||||
? 'this empty folder'
|
||||
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
|
||||
<h2 className="mb-1 text-base font-semibold text-text">
|
||||
Delete folder "{folderName}"?
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
What should happen to {photoBlurb}?
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ModeCard
|
||||
icon={<Archive className="h-4 w-4" />}
|
||||
title="Move photos to discard pile"
|
||||
description="Photos can be restored later from Discarded. The folder and files stay on disk."
|
||||
selected={mode === 'discard'}
|
||||
onClick={() => setMode('discard')}
|
||||
/>
|
||||
<ModeCard
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
title="Permanently delete folder and photos"
|
||||
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
|
||||
selected={mode === 'permanent'}
|
||||
destructive
|
||||
onClick={() => setMode('permanent')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(mode)}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1.5 text-sm font-medium text-white',
|
||||
mode === 'permanent'
|
||||
? 'bg-reject hover:bg-reject/80'
|
||||
: 'bg-primary hover:bg-primary/80'
|
||||
)}
|
||||
>
|
||||
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
selected,
|
||||
destructive = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
selected: boolean
|
||||
destructive?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
|
||||
selected
|
||||
? destructive
|
||||
? 'border-reject/60 bg-reject/10'
|
||||
: 'border-primary/60 bg-primary/10'
|
||||
: 'border-border bg-surface-2 hover:bg-surface-offset'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-0.5 flex-shrink-0',
|
||||
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'text-sm font-medium',
|
||||
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { discard as discardApi, photos as photosApi } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
|
||||
/**
|
||||
* Top-of-timeline bar visible only when the discarded filter is active.
|
||||
* Shows a count, lets the user restore the current selection, and lets them
|
||||
* permanently empty the discard pile (with confirmation).
|
||||
*/
|
||||
export function DiscardActionBar() {
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||
const clearSelection = usePhotoStore((s) => s.clearSelection)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: photos = [] } = usePhotosQuery()
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false)
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
||||
onSuccess: (_, ids) => {
|
||||
registerUndoable(
|
||||
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||
async () => {
|
||||
await photosApi.bulkDiscard(ids)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
)
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const deleteSelectedMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
|
||||
onSuccess: (data: any) => {
|
||||
const count = data?.deleted ?? 0
|
||||
const errors = data?.file_errors ?? 0
|
||||
if (errors > 0) {
|
||||
toast.error(
|
||||
`Deleted with ${errors} error${errors > 1 ? 's' : ''}`,
|
||||
`${count} record${count === 1 ? '' : 's'} deleted; some files could not be removed`
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
'Permanently deleted',
|
||||
`${count} photo${count === 1 ? '' : 's'} removed from disk`
|
||||
)
|
||||
}
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
setDeleteSelectedOpen(false)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Delete failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const emptyMutation = useMutation({
|
||||
mutationFn: () => discardApi.empty(),
|
||||
onSuccess: (data: any) => {
|
||||
const count = data?.deleted ?? 0
|
||||
const errors = data?.file_errors ?? 0
|
||||
if (errors > 0) {
|
||||
toast.error(
|
||||
`Emptied with ${errors} error${errors > 1 ? 's' : ''}`,
|
||||
`${count} record${count > 1 ? 's' : ''} deleted; some files could not be removed`
|
||||
)
|
||||
} else {
|
||||
toast.success('Discard pile emptied', `${count} photo${count > 1 ? 's' : ''} permanently deleted`)
|
||||
}
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
setConfirmOpen(false)
|
||||
},
|
||||
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
if (flag !== 'discarded') return null
|
||||
|
||||
const total = photos.length
|
||||
const selected = selectedPhotos.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border bg-reject/10 px-4 py-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Trash2 className="h-4 w-4 text-reject" />
|
||||
<span className="font-medium">Discarded</span>
|
||||
<span className="text-text-muted">
|
||||
{total} photo{total === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selected > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-2 px-3 py-1 text-text hover:bg-surface-offset disabled:opacity-50"
|
||||
title="Restore selected (U)"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Restore {selected}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteSelectedOpen(true)}
|
||||
disabled={deleteSelectedMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||
title="Permanently delete selected"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete {selected}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={total === 0 || emptyMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||
title="Permanently delete all discarded photos and files"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Empty discard pile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteSelectedOpen}
|
||||
title={`Delete ${selected} photo${selected === 1 ? '' : 's'}?`}
|
||||
message={
|
||||
<>
|
||||
This will <strong className="text-text">permanently delete</strong>{' '}
|
||||
{selected} photo{selected === 1 ? '' : 's'} and remove the file
|
||||
{selected === 1 ? '' : 's'} from disk. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={() => deleteSelectedMutation.mutate(selectedPhotos)}
|
||||
onClose={() => setDeleteSelectedOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
title="Empty discard pile?"
|
||||
message={
|
||||
<>
|
||||
This will <strong className="text-text">permanently delete</strong>{' '}
|
||||
{total} photo{total === 1 ? '' : 's'} and remove the file
|
||||
{total === 1 ? '' : 's'} from disk. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Empty pile"
|
||||
destructive
|
||||
onConfirm={() => emptyMutation.mutate()}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Star, X, ArrowDown, ArrowUp, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
useFilterStore,
|
||||
hasActiveFilters,
|
||||
type MediaType,
|
||||
type SortField,
|
||||
} from '../../store/filterStore'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { FilterPill } from './FilterPill'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'raw', label: 'RAW' },
|
||||
{ value: 'heic', label: 'HEIC' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: 'taken_at', label: 'Date taken' },
|
||||
{ value: 'added_at', label: 'Date added' },
|
||||
{ value: 'filename', label: 'Filename' },
|
||||
{ value: 'file_size', label: 'File size' },
|
||||
{ value: 'rating', label: 'Rating' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Compact, always-visible filter toolbar built out of FilterPill primitives.
|
||||
* Each pill represents a filter category, opens a popover with the
|
||||
* underlying control, and shows a short value summary inline when active.
|
||||
* Replaces the old expandable FilterBar + ActiveFilterChips combo.
|
||||
*/
|
||||
export function FilterBar() {
|
||||
const filterState = useFilterStore()
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||
const tagIds = useFilterStore((s) => s.tagIds)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
|
||||
// Only the Flag pill is hidden inside the Discarded section. Flag has
|
||||
// exactly two values and the section locks one of them, so the pill
|
||||
// would only ever toggle the section off — useless. Rating + Tags
|
||||
// pills stay visible in their sections because the user can refine
|
||||
// them further (ratingMin >= 3, restrict to specific tag ids).
|
||||
const hideFlagPill = currentSection === 'discarded'
|
||||
|
||||
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
||||
const setDateTo = useFilterStore((s) => s.setDateTo)
|
||||
const toggleMediaType = useFilterStore((s) => s.toggleMediaType)
|
||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
||||
const setFlag = useFilterStore((s) => s.setFlag)
|
||||
const setTagIds = useFilterStore((s) => s.setTagIds)
|
||||
const toggleTagId = useFilterStore((s) => s.toggleTagId)
|
||||
const setSortBy = useFilterStore((s) => s.setSortBy)
|
||||
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
|
||||
const clearAll = useFilterStore((s) => s.clearAll)
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
|
||||
// Search box. Local state mirrors the store so typing stays responsive
|
||||
// while we debounce store writes (each store write triggers a re-fetch).
|
||||
const storeQ = useFilterStore((s) => s.q)
|
||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
||||
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeQ)
|
||||
}, [storeQ])
|
||||
const debounceRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchQuery === storeQ) return
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
setStoreQ(searchQuery)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
// Pre-compute pill values + active flags so the JSX stays terse.
|
||||
const dateActive = dateFrom !== null || dateTo !== null
|
||||
const dateValue = dateActive
|
||||
? `${dateFrom ?? '…'} → ${dateTo ?? '…'}`
|
||||
: null
|
||||
|
||||
const typeActive = mediaTypes.length > 0
|
||||
const typeValue = typeActive
|
||||
? mediaTypes.map((t) => t.toUpperCase()).join(', ')
|
||||
: null
|
||||
|
||||
const ratingActive = ratingMin > 0
|
||||
const ratingValue = ratingActive ? `≥ ${ratingMin}★` : null
|
||||
|
||||
const colorActive = colorLabel !== null
|
||||
const colorValue = colorActive ? colorLabel : null
|
||||
|
||||
const flagActive = flag !== 'any'
|
||||
const flagValue = flagActive ? flag : null
|
||||
|
||||
const tagActive = tagIds.length > 0
|
||||
const activeTagNames = allTags
|
||||
.filter((t) => tagIds.includes(t.id))
|
||||
.map((t) => t.name)
|
||||
const tagValue = tagActive
|
||||
? activeTagNames.length <= 2
|
||||
? activeTagNames.join(', ')
|
||||
: `${activeTagNames.slice(0, 2).join(', ')} +${activeTagNames.length - 2}`
|
||||
: null
|
||||
|
||||
const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? sortBy
|
||||
const sortValue = `${sortLabel} ${sortOrder === 'desc' ? '↓' : '↑'}`
|
||||
|
||||
const anyActive = hasActiveFilters(filterState)
|
||||
|
||||
return (
|
||||
// Fixed bar height + py-0 so neither the active filter pills nor the
|
||||
// clear-all button can stretch the bar vertically. The fixed h-11
|
||||
// matches the h-7 pills + 8px symmetric vertical padding.
|
||||
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-3 py-0">
|
||||
{/* Pills — left side, scroll horizontally if they overflow. */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||
{/* Date */}
|
||||
<FilterPill
|
||||
label="Date"
|
||||
value={dateValue}
|
||||
isActive={dateActive}
|
||||
onClear={() => {
|
||||
setDateFrom(null)
|
||||
setDateTo(null)
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-text-muted">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom ?? ''}
|
||||
onChange={(e) => setDateFrom(e.target.value || null)}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-text-muted">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo ?? ''}
|
||||
onChange={(e) => setDateTo(e.target.value || null)}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterPill>
|
||||
|
||||
{/* Type */}
|
||||
<FilterPill
|
||||
label="Type"
|
||||
value={typeValue}
|
||||
isActive={typeActive}
|
||||
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{MEDIA_TYPES.map(({ value, label }) => {
|
||||
const active = mediaTypes.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => toggleMediaType(value)}
|
||||
className={clsx(
|
||||
'rounded px-2 py-1 text-xs transition-colors',
|
||||
active
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FilterPill>
|
||||
|
||||
{/* Rating */}
|
||||
<FilterPill
|
||||
label="Rating"
|
||||
value={ratingValue}
|
||||
isActive={ratingActive}
|
||||
onClear={() => setRatingMin(0)}
|
||||
>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
|
||||
className="p-0.5"
|
||||
title={`At least ${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={clsx(
|
||||
'h-5 w-5 transition-colors',
|
||||
n <= ratingMin
|
||||
? 'fill-star text-star'
|
||||
: 'text-text-muted hover:text-star'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FilterPill>
|
||||
|
||||
{/* Color */}
|
||||
<FilterPill
|
||||
label="Color"
|
||||
value={colorValue}
|
||||
isActive={colorActive}
|
||||
onClear={() => setColorLabel(null)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
||||
const active = colorLabel === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setColorLabel(active ? null : value)}
|
||||
className={clsx(
|
||||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||||
className,
|
||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title={value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{colorLabel && (
|
||||
<button
|
||||
onClick={() => setColorLabel(null)}
|
||||
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear color"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</FilterPill>
|
||||
|
||||
{/* Flag — hidden in the Discarded section, where the flag is
|
||||
* pinned to "discarded" by the section preset. */}
|
||||
{!hideFlagPill && (
|
||||
<FilterPill
|
||||
label="Flag"
|
||||
value={flagValue}
|
||||
isActive={flagActive}
|
||||
onClear={() => setFlag('any')}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => setFlag('any')}
|
||||
className={clsx(
|
||||
'rounded px-2 py-1 text-left text-xs transition-colors',
|
||||
flag === 'any'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
Any
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFlag('discarded')}
|
||||
className={clsx(
|
||||
'rounded px-2 py-1 text-left text-xs transition-colors',
|
||||
flag === 'discarded'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
Discarded
|
||||
</button>
|
||||
</div>
|
||||
</FilterPill>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{allTags.length > 0 && (
|
||||
<FilterPill
|
||||
label="Tags"
|
||||
value={tagValue}
|
||||
isActive={tagActive}
|
||||
onClear={() => setTagIds([])}
|
||||
>
|
||||
<div className="flex max-h-60 flex-wrap gap-1 overflow-y-auto">
|
||||
{allTags.map((tag) => {
|
||||
const active = tagIds.includes(tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTagId(tag.id)}
|
||||
className={clsx(
|
||||
'rounded px-2 py-1 text-xs transition-colors',
|
||||
active
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FilterPill>
|
||||
)}
|
||||
|
||||
{/* Sort — always present, never "active/inactive" since there's
|
||||
always a value. */}
|
||||
<FilterPill label="Sort" value={sortValue} isActive>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortField)}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={toggleSortOrder}
|
||||
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
>
|
||||
{sortOrder === 'desc' ? (
|
||||
<>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
Descending
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
Ascending
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FilterPill>
|
||||
|
||||
{/* Clear-all — borderless text affordance pinned next to the pill
|
||||
* cluster on the right. Lives inside the pills container so it
|
||||
* shares the same flex group and gap and reads as "another
|
||||
* pill". Only renders when any filter is active. */}
|
||||
{anyActive && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
|
||||
title="Clear all filters in this section"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search — pinned to the right edge of the bar. Same id as before
|
||||
* so the global "/" focus shortcut still finds it. */}
|
||||
<div className="relative w-56 flex-shrink-0">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
id="topbar-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Search photos…"
|
||||
className="w-full rounded-full border border-border bg-surface-2 py-1 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
title="Clear search (Esc)"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface FilterPillProps {
|
||||
/** Category label, always shown ("Date", "Type", etc.). */
|
||||
label: string
|
||||
/** Currently unused in the rendered output — the inline value display
|
||||
* was making active pills wider than inactive ones. Kept on the
|
||||
* interface so callers don't have to change. The value is still
|
||||
* surfaced via the title attribute for hover discovery. */
|
||||
value?: string | null
|
||||
isActive?: boolean
|
||||
/** When provided + isActive, an X appears inside the pill that clears
|
||||
* this filter without opening the popover. */
|
||||
onClear?: () => void
|
||||
/** Popover contents — usually the existing control for this filter. */
|
||||
children: React.ReactNode
|
||||
/** Force the popover open programmatically (rare). */
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A toolbar pill that hosts a filter category. Click the pill to open a
|
||||
* small popover with the actual control; the popover closes on outside
|
||||
* click or Escape. Active filters tint the pill primary and show their
|
||||
* current value inline.
|
||||
*/
|
||||
export function FilterPill({
|
||||
label,
|
||||
value,
|
||||
isActive = false,
|
||||
onClear,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: FilterPillProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Compute the popover's screen position from the trigger button. Done
|
||||
// imperatively (not via CSS absolute) so the popover can live in a portal
|
||||
// and escape the FilterBar's overflow-x-auto clipping. Re-computed on
|
||||
// open, scroll, and resize.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
const update = () => {
|
||||
const btn = buttonRef.current
|
||||
if (!btn) return
|
||||
const rect = btn.getBoundingClientRect()
|
||||
// Default left-align under the trigger; clamp to viewport so the
|
||||
// last pill on the right doesn't overflow.
|
||||
const popWidth = popoverRef.current?.offsetWidth ?? 240
|
||||
const margin = 8
|
||||
let left = rect.left
|
||||
if (left + popWidth + margin > window.innerWidth) {
|
||||
left = Math.max(margin, window.innerWidth - popWidth - margin)
|
||||
}
|
||||
setPopoverPos({ top: rect.bottom + 4, left })
|
||||
}
|
||||
update()
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on outside click + Escape. Outside means neither the trigger
|
||||
// button nor the (portaled) popover.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (buttonRef.current?.contains(target)) return
|
||||
if (popoverRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
// Hover to see the active value as a tooltip — keeps the pill at
|
||||
// a constant width regardless of state. The popover is the
|
||||
// canonical place to read/edit the filter value.
|
||||
title={isActive && value ? `${label}: ${value}` : label}
|
||||
className={clsx(
|
||||
// Fixed height + py-0 so neither the X clear icon nor the
|
||||
// chevron can stretch the pill vertically when the active
|
||||
// state swaps them in.
|
||||
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
|
||||
isActive
|
||||
? 'border-primary/40 bg-primary/15 text-primary'
|
||||
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(isActive && 'font-medium')}>{label}</span>
|
||||
{isActive && onClear ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}
|
||||
}}
|
||||
// Same h-4 w-4 as the chevron slot below so swapping the
|
||||
// two doesn't change the pill's footprint.
|
||||
className="ml-0.5 inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
|
||||
title={`Clear ${label}`}
|
||||
aria-label={`Clear ${label}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center">
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: popoverPos?.top ?? -9999,
|
||||
left: popoverPos?.left ?? -9999,
|
||||
visibility: popoverPos ? 'visible' : 'hidden',
|
||||
}}
|
||||
className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { X, Folder, AlertCircle } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
heaps as heapsApi,
|
||||
type Heap,
|
||||
type FolderTreeNode,
|
||||
} from '../../services/api'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
interface FlatFolder {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** Walk the folder tree depth-first into a flat list with depth info so
|
||||
* the picker can render every node — including nested subfolders — as
|
||||
* one indented option. */
|
||||
function flattenTree(nodes: FolderTreeNode[], depth = 0): FlatFolder[] {
|
||||
const out: FlatFolder[] = []
|
||||
for (const n of nodes) {
|
||||
out.push({ id: n.id, name: n.name, path: n.path, depth })
|
||||
if (n.children.length > 0) {
|
||||
out.push(...flattenTree(n.children, depth + 1))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface HeapConvertDialogProps {
|
||||
heap: Heap | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that converts a heap into a folder. The user picks a target folder
|
||||
* (any source root, today — sub-folder picking is a follow-up), chooses
|
||||
* move vs copy semantics, and optionally has the heap deleted on success.
|
||||
*/
|
||||
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [mode, setMode] = useState<'move' | 'copy'>('move')
|
||||
const [deleteHeap, setDeleteHeap] = useState(false)
|
||||
const [subfolderName, setSubfolderName] = useState('')
|
||||
|
||||
// Use the recursive folder tree, not the flat source-root list, so the
|
||||
// user can pick a sub-folder at any depth as the target.
|
||||
const { data: tree = [] } = useFolderTreeQuery()
|
||||
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
|
||||
|
||||
// Default to the first folder when the dialog opens or folders load.
|
||||
useEffect(() => {
|
||||
if (!targetId && folders.length > 0) {
|
||||
setTargetId(folders[0].id)
|
||||
}
|
||||
}, [folders, targetId])
|
||||
|
||||
// Reset state on close, prefill subfolder name when opened.
|
||||
useEffect(() => {
|
||||
if (heap) {
|
||||
setSubfolderName(heap.name)
|
||||
} else {
|
||||
setTargetId('')
|
||||
setMode('move')
|
||||
setDeleteHeap(false)
|
||||
setSubfolderName('')
|
||||
}
|
||||
}, [heap])
|
||||
|
||||
const convertMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
heapsApi.convert(heap!.id, {
|
||||
target_id: targetId,
|
||||
mode,
|
||||
delete_heap: deleteHeap,
|
||||
// Empty subfolder = drop directly into the parent. Trim and only
|
||||
// send if the user kept it populated.
|
||||
subfolder_name: subfolderName.trim() || null,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
const total = (data.moved ?? 0) + (data.copied ?? 0)
|
||||
const verb = data.mode === 'move' ? 'Moved' : 'Copied'
|
||||
toast.success(
|
||||
`${verb} ${total} photo${total === 1 ? '' : 's'}`,
|
||||
data.heap_deleted ? `Heap "${heap?.name}" deleted` : undefined
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Convert failed', e?.response?.data?.detail || e.message),
|
||||
})
|
||||
|
||||
if (!heap) return null
|
||||
|
||||
const targetFolder = folders.find((f) => f.id === targetId)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
Convert "{heap.name}" to folder
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={convertMutation.isPending}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Target picker */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
|
||||
{folders.length === 0 ? (
|
||||
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
|
||||
No folders available
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
|
||||
>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{/* Two non-breaking spaces per depth so nested
|
||||
* subfolders read as a tree in the native dropdown. */}
|
||||
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{targetFolder && (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
|
||||
<Folder className="h-3 w-3" />
|
||||
{targetFolder.path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subfolder name */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-xs text-text-muted">
|
||||
Subfolder name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subfolderName}
|
||||
onChange={(e) => setSubfolderName(e.target.value)}
|
||||
placeholder="(none — use parent directly)"
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
{subfolderName.trim() && targetFolder
|
||||
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
|
||||
: 'Photos go directly into the parent folder.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-xs text-text-muted">Mode</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMode('move')}
|
||||
className={clsx(
|
||||
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
|
||||
mode === 'move'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('copy')}
|
||||
className={clsx(
|
||||
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
|
||||
mode === 'copy'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
{mode === 'move'
|
||||
? 'Files are moved on disk; original photos update their folder.'
|
||||
: 'Files are copied on disk; new photo records are created.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete heap toggle */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<input
|
||||
id="delete-heap"
|
||||
type="checkbox"
|
||||
checked={deleteHeap}
|
||||
onChange={(e) => setDeleteHeap(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<label htmlFor="delete-heap" className="text-sm text-text">
|
||||
Delete heap after conversion
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{convertMutation.isError && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={convertMutation.isPending}
|
||||
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => convertMutation.mutate()}
|
||||
disabled={!targetId || convertMutation.isPending}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{convertMutation.isPending ? 'Converting…' : 'Convert'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ShoppingBasket,
|
||||
Plus,
|
||||
Target,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderOutput,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { heaps as heapsApi, type Heap } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||
import { HeapConvertDialog } from './HeapConvertDialog'
|
||||
|
||||
/**
|
||||
* Heaps panel for the left sidebar. Renders the list of heaps with the
|
||||
* basket icon, lets the user create a new heap, click one to filter the
|
||||
* timeline to its contents, set one as the "active" target for the T
|
||||
* shortcut, and delete heaps.
|
||||
*
|
||||
* Heap state:
|
||||
* - filter heapId: which heap is currently filtered to (visual)
|
||||
* - heap.is_active: which heap T adds to (server-side, single per row)
|
||||
*/
|
||||
export function HeapsPanel() {
|
||||
const { data: heaps = [] } = useHeapsQuery()
|
||||
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
// Which heap row is currently being hovered with a drag — used to render
|
||||
// the drop highlight ring. Only one heap can be the target at a time.
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
|
||||
// Inline rename state for heap rows: stores the heap id being edited and
|
||||
// the draft name. Mirrors the folder rename pattern in LeftSidebar.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
// Which heap's burger menu is currently open. null when no menu is open.
|
||||
// The popover closes on outside click and Escape via the effect below.
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenuId) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpenMenuId(null)
|
||||
}
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpenMenuId(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openMenuId])
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => heapsApi.create(name),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setNewName('')
|
||||
setCreating(false)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to create heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const setActiveMutation = useMutation({
|
||||
mutationFn: (heapId: string) =>
|
||||
heapsApi.update(heapId, { is_active: true }),
|
||||
onSuccess: (heap) => {
|
||||
invalidate()
|
||||
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to set active', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (heapId: string) => heapsApi.delete(heapId),
|
||||
onSuccess: (_, heapId) => {
|
||||
invalidate()
|
||||
// If we were viewing this heap, snap back to all-photos.
|
||||
if (currentSection === `heap-${heapId}`) {
|
||||
navigateToSection('all-photos', {})
|
||||
}
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ heapId, name }: { heapId: string; name: string }) =>
|
||||
heapsApi.update(heapId, { name }),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to rename heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: (heapId: string) => heapsApi.duplicate(heapId),
|
||||
onSuccess: (heap) => {
|
||||
invalidate()
|
||||
toast.success('Heap duplicated', heap.name)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to duplicate heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Drop handler: add the dragged photos to the target heap. Optimistically
|
||||
// updates the membership cache so the basket affordance flips immediately,
|
||||
// mirroring the keyboard P-toggle pattern.
|
||||
const dropMutation = useMutation({
|
||||
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
|
||||
heapsApi.addPhotos(heapId, photoIds),
|
||||
onMutate: ({ heapId, photoIds }) => {
|
||||
const key = ['heap-photo-ids', heapId] as const
|
||||
const previous = queryClient.getQueryData<string[]>(key)
|
||||
const set = new Set(previous ?? [])
|
||||
photoIds.forEach((id) => set.add(id))
|
||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||
return { previous }
|
||||
},
|
||||
onError: (e: any, vars, ctx) => {
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
|
||||
}
|
||||
toast.error('Failed to add to heap', e.message || 'Unknown error')
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
const heap = heaps.find((h) => h.id === vars.heapId)
|
||||
const heapName = heap?.name ?? 'heap'
|
||||
const added = data?.added ?? 0
|
||||
const already = data?.already_present ?? 0
|
||||
if (added > 0) {
|
||||
toast.success(
|
||||
`Added to ${heapName}`,
|
||||
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||
)
|
||||
} else if (already > 0) {
|
||||
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
|
||||
}
|
||||
},
|
||||
onSettled: (_d, _e, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
createMutation.mutate(name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm text-text hover:bg-surface-2"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<button className="rounded p-0.5 hover:bg-surface-offset">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<ShoppingBasket className="h-4 w-4 text-text-muted" />
|
||||
<span className="flex-1 truncate">Heaps</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCreating(true)
|
||||
setExpanded(true)
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
title="New heap"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Inline create form */}
|
||||
{creating && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1"
|
||||
style={{ paddingLeft: '32px' }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
if (e.key === 'Escape') {
|
||||
setCreating(false)
|
||||
setNewName('')
|
||||
}
|
||||
}}
|
||||
placeholder="Heap name"
|
||||
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
className="rounded bg-primary px-2 py-0.5 text-xs text-white hover:bg-primary/80 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heaps.length === 0 && !creating && (
|
||||
<div
|
||||
className="px-2 py-1 text-xs text-text-faint"
|
||||
style={{ paddingLeft: '32px' }}
|
||||
>
|
||||
No heaps yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heaps.map((heap) => {
|
||||
const isFiltered = currentSection === `heap-${heap.id}`
|
||||
const isActive = heap.is_active
|
||||
const isDropTarget = dropTargetId === heap.id
|
||||
const isRenaming = renamingId === heap.id
|
||||
const isMenuOpen = openMenuId === heap.id
|
||||
|
||||
const commitRename = () => {
|
||||
const next = renameDraft.trim()
|
||||
if (next && next !== heap.name) {
|
||||
renameMutation.mutate({ heapId: heap.id, name: next })
|
||||
}
|
||||
setRenamingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={heap.id}
|
||||
className={clsx(
|
||||
'group relative flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||
)}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenamingId(heap.id)
|
||||
setRenameDraft(heap.name)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
if (dropTargetId !== heap.id) setDropTargetId(heap.id)
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
if (dropTargetId === heap.id) setDropTargetId(null)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDropTargetId(null)
|
||||
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
||||
if (!raw) return
|
||||
try {
|
||||
const ids = JSON.parse(raw) as string[]
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
dropMutation.mutate({ heapId: heap.id, photoIds: ids })
|
||||
}
|
||||
} catch {
|
||||
// Bad payload — ignore.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShoppingBasket
|
||||
className={clsx(
|
||||
'h-4 w-4 flex-shrink-0',
|
||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
/>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenamingId(null)
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Right cluster. Count is the rightmost element in the
|
||||
* resting state — set-active and kebab use display:none
|
||||
* (not invisible) so they reserve no width until hover,
|
||||
* keeping the count column aligned with the rest of the
|
||||
* sidebar. The active heap is signaled by font-semibold
|
||||
* on the name above; the standalone Target indicator
|
||||
* was making heap counts sit left of the others. */}
|
||||
{heap.photo_count > 0 ? (
|
||||
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
||||
{heap.photo_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveMutation.mutate(heap.id)
|
||||
}}
|
||||
className="hidden flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:block"
|
||||
title="Set as active heap (T target)"
|
||||
aria-label="Set as active heap"
|
||||
>
|
||||
<Target className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Kebab menu — collects rename / duplicate / convert /
|
||||
* delete so the row stays compact. */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex-shrink-0',
|
||||
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenMenuId(isMenuOpen ? null : heap.id)
|
||||
}}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
title="More actions"
|
||||
aria-label="More heap actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem
|
||||
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||
label="Rename"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setRenamingId(heap.id)
|
||||
setRenameDraft(heap.name)
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
duplicateMutation.mutate(heap.id)
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<FolderOutput className="h-3.5 w-3.5" />}
|
||||
label="Move to folder…"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setConvertingHeap(heap)
|
||||
}}
|
||||
/>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<MenuItem
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
label="Delete"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
if (
|
||||
confirm(
|
||||
`Delete heap "${heap.name}"? Photos are not affected.`
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(heap.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HeapConvertDialog
|
||||
heap={convertingHeap}
|
||||
onClose={() => setConvertingHeap(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
destructive = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
destructive?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
|
||||
destructive
|
||||
? 'text-reject hover:bg-reject/10'
|
||||
: 'text-text hover:bg-surface-2'
|
||||
)}
|
||||
>
|
||||
<span className="text-text-muted">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,738 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Image,
|
||||
Star,
|
||||
Trash2,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Tag as TagIcon,
|
||||
Layers2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import {
|
||||
useLibraryStatsQuery,
|
||||
LIBRARY_STATS_QUERY_KEY,
|
||||
} from '../../hooks/useLibraryStatsQuery'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||
|
||||
interface TreeItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
count?: number
|
||||
children?: TreeItem[]
|
||||
type?: 'folder' | 'heap' | 'special'
|
||||
}
|
||||
|
||||
export function LeftSidebar() {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
// Inline rename state for source-root rows. Stores the id being edited
|
||||
// and the draft name. Double-click a folder row to start.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const { data: stats } = useLibraryStatsQuery()
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
|
||||
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
|
||||
// or "folders" for the section header). Outside-click + Escape close.
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!openMenuId) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpenMenuId(null)
|
||||
}
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpenMenuId(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openMenuId])
|
||||
|
||||
// "Create new folder under {parent}" inline state. parentId is the
|
||||
// Folder.id (no "folder-" prefix).
|
||||
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
|
||||
const [createDraft, setCreateDraft] = useState('')
|
||||
|
||||
// Folder being deleted, drives the DeleteFolderDialog mounted below.
|
||||
const [deletingFolder, setDeletingFolder] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
photoCount?: number
|
||||
} | null>(null)
|
||||
|
||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||
const discardDropMutation = useMutation({
|
||||
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
||||
onSuccess: (_data, photoIds) => {
|
||||
registerUndoable(
|
||||
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
|
||||
async () => {
|
||||
await photosApi.bulkRestore(photoIds)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Bulk move mutation for the drag-onto-folder interaction. The mutation
|
||||
// captures each photo's source folder before issuing the move so the
|
||||
// undo path can put them back exactly where they came from (different
|
||||
// sources end up in different undo subgroups).
|
||||
const moveDropMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
targetId,
|
||||
photoIds,
|
||||
}: {
|
||||
targetId: string
|
||||
photoIds: string[]
|
||||
}) => {
|
||||
// Snapshot per-photo source folder ids from the photos cache. We
|
||||
// walk every cached ['photos', ...] entry because the user could
|
||||
// be in any section / filter combination, and we don't know the
|
||||
// exact key offhand.
|
||||
const sourceMap = new Map<string, string>()
|
||||
const photoCaches = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
|
||||
for (const [, list] of photoCaches) {
|
||||
if (!list) continue
|
||||
for (const p of list) {
|
||||
if (photoIds.includes(p.id) && p.folder_id && !sourceMap.has(p.id)) {
|
||||
sourceMap.set(p.id, p.folder_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await photosApi.move(photoIds, targetId)
|
||||
return { result, sourceMap }
|
||||
},
|
||||
onSuccess: ({ result, sourceMap }) => {
|
||||
const moved = result?.moved ?? 0
|
||||
const errCount = result?.errors?.length ?? 0
|
||||
if (moved > 0) {
|
||||
// Group photos by their source folder so we can issue one move
|
||||
// call per group when undoing. Photos whose source folder we
|
||||
// couldn't recover get dropped from the undo (they'll just stay
|
||||
// where the move put them).
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const [photoId, src] of sourceMap.entries()) {
|
||||
const arr = groups.get(src) ?? []
|
||||
arr.push(photoId)
|
||||
groups.set(src, arr)
|
||||
}
|
||||
if (groups.size > 0) {
|
||||
registerUndoable(
|
||||
`Moved ${moved} photo${moved === 1 ? '' : 's'}`,
|
||||
async () => {
|
||||
for (const [src, ids] of groups.entries()) {
|
||||
await photosApi.move(ids, src)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
'Moved',
|
||||
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
||||
)
|
||||
}
|
||||
} else if (errCount > 0) {
|
||||
toast.error(
|
||||
'Move failed',
|
||||
`${errCount} file${errCount > 1 ? 's' : ''} could not be moved`
|
||||
)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Bulk copy mutation — Alt-drag uses this instead of move.
|
||||
const copyDropMutation = useMutation({
|
||||
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
|
||||
photosApi.copy(photoIds, targetId),
|
||||
onSuccess: (data) => {
|
||||
const copied = data?.copied ?? 0
|
||||
const errCount = data?.errors?.length ?? 0
|
||||
if (copied > 0) {
|
||||
toast.success(
|
||||
'Copied',
|
||||
`${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
||||
)
|
||||
} else if (errCount > 0) {
|
||||
toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Reads the dragged ids out of a drop event payload.
|
||||
const readDragIds = (e: React.DragEvent): string[] | null => {
|
||||
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as string[]
|
||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Map a library tree id to a section navigation. Each "virtual node" in
|
||||
// the library tree is its own section, with its own remembered filter
|
||||
// state. The preset is the section's intrinsic filter (the thing that
|
||||
// makes it that section); user-added filters from the FilterBar layer
|
||||
// on top and are saved when the user navigates away.
|
||||
const applyLibraryNode = (id: string) => {
|
||||
switch (id) {
|
||||
case 'all-photos':
|
||||
navigateToSection('all-photos', {})
|
||||
break
|
||||
case 'rated':
|
||||
navigateToSection('rated', { ratingMin: 1 })
|
||||
break
|
||||
case 'discarded':
|
||||
navigateToSection('discarded', { flag: 'discarded' })
|
||||
break
|
||||
case 'duplicates':
|
||||
navigateToSection('duplicates', { duplicates: true })
|
||||
break
|
||||
case 'tags':
|
||||
navigateToSection('tags', { groupBy: 'tag' })
|
||||
break
|
||||
default:
|
||||
if (id.startsWith('folder-')) {
|
||||
const folderId = id.slice('folder-'.length)
|
||||
navigateToSection(`folder-${folderId}`, { folderId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the recursive folder tree (one root per active source root).
|
||||
const { data: folderTree = [] } = useFolderTreeQuery()
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
sourceFolders.rename(id, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const createFolderMutation = useMutation({
|
||||
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
|
||||
sourceFolders.create(parentId, name),
|
||||
onSuccess: (data) => {
|
||||
toast.success('Folder created', data.name)
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
setCreatingUnder(null)
|
||||
setCreateDraft('')
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const deleteFolderMutation = useMutation({
|
||||
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
|
||||
sourceFolders.delete(id, mode),
|
||||
onSuccess: (data) => {
|
||||
if (data.mode === 'discard') {
|
||||
toast.success(
|
||||
'Folder photos discarded',
|
||||
`${data.discarded ?? 0} moved to discard pile`
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
'Folder deleted',
|
||||
`${data.deleted_photos ?? 0} photos removed from disk`
|
||||
)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
// If we were viewing the deleted folder, snap back to all-photos.
|
||||
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
|
||||
navigateToSection('all-photos', {})
|
||||
}
|
||||
setDeletingFolder(null)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Mutation for scanning all folders
|
||||
const scanLibraryMutation = useMutation({
|
||||
mutationFn: library.scan,
|
||||
onMutate: () => {
|
||||
setIsScanning(true)
|
||||
toast.info('Scan Started', 'Scanning all folders for new photos...')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Scan Complete', 'All folders have been scanned')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error('Scan Failed', error.message || 'Failed to scan folders')
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsScanning(false)
|
||||
// Refetch photos after scan
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleScanAll = () => {
|
||||
scanLibraryMutation.mutate()
|
||||
}
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id)
|
||||
} else {
|
||||
newExpanded.add(id)
|
||||
}
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
// Recursively map a backend FolderTreeNode into our generic TreeItem.
|
||||
const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({
|
||||
id: `folder-${node.id}`,
|
||||
label: node.name,
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
count: node.photo_count,
|
||||
type: 'folder',
|
||||
children: node.children.length > 0
|
||||
? node.children.map(folderNodeToTreeItem)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Total tag count for the badge on the Tags entry.
|
||||
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
|
||||
|
||||
const libraryTree: TreeItem[] = [
|
||||
{
|
||||
id: 'library',
|
||||
label: 'Views',
|
||||
icon: <Layers2 className="h-4 w-4" />,
|
||||
children: [
|
||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
|
||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
|
||||
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'folders',
|
||||
label: 'Folders',
|
||||
icon: <HardDrive className="h-4 w-4" />,
|
||||
children: folderTree.map(folderNodeToTreeItem),
|
||||
},
|
||||
]
|
||||
|
||||
// Derive whether a tree item is currently the "active" filter target.
|
||||
// Folder rows are selected when the filter store's folderId matches; the
|
||||
// library "All Photos" virtual node is selected when no folder/heap filter
|
||||
// is set.
|
||||
// Active highlight is now driven entirely by currentSection. Each
|
||||
// library node and folder row maps 1:1 to a section id.
|
||||
const isItemActive = (id: string): boolean => {
|
||||
if (id.startsWith('folder-')) {
|
||||
return currentSection === id
|
||||
}
|
||||
return currentSection === id
|
||||
}
|
||||
|
||||
// Which tree items accept photo drops, and what each does on drop.
|
||||
const isDropTarget = (id: string): boolean => {
|
||||
return id === 'discarded' || id.startsWith('folder-')
|
||||
}
|
||||
|
||||
const handleDrop = (id: string, ids: string[], copy: boolean) => {
|
||||
if (id === 'discarded') {
|
||||
discardDropMutation.mutate(ids)
|
||||
return
|
||||
}
|
||||
if (id.startsWith('folder-')) {
|
||||
const targetId = id.slice('folder-'.length)
|
||||
if (copy) {
|
||||
copyDropMutation.mutate({ targetId, photoIds: ids })
|
||||
} else {
|
||||
moveDropMutation.mutate({ targetId, photoIds: ids })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedItems.has(item.id)
|
||||
const isSelected = isItemActive(item.id)
|
||||
const acceptsDrop = isDropTarget(item.id)
|
||||
const isDropHover = dropTargetId === item.id
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
|
||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
isDropHover && (item.id === 'discarded'
|
||||
? 'ring-2 ring-reject bg-reject/10'
|
||||
: 'ring-2 ring-primary bg-primary/10'),
|
||||
depth > 0 && 'text-[13px]'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||
onClick={() => {
|
||||
if (renamingId === item.id) return
|
||||
// Folder rows are always filterable, parent or leaf — clicking
|
||||
// anywhere on the row applies the filter and the chevron
|
||||
// (separate button below) handles expansion. Other group
|
||||
// headers (Library, Folders) just toggle expansion since
|
||||
// they have no associated section.
|
||||
if (item.id.startsWith('folder-')) {
|
||||
applyLibraryNode(item.id)
|
||||
} else if (hasChildren) {
|
||||
toggleExpanded(item.id)
|
||||
} else {
|
||||
applyLibraryNode(item.id)
|
||||
}
|
||||
}}
|
||||
onDoubleClick={
|
||||
item.id.startsWith('folder-')
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
setRenamingId(item.id)
|
||||
setRenameDraft(item.label)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragOver={acceptsDrop ? (e) => {
|
||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||
e.preventDefault()
|
||||
// Alt held → copy (only meaningful for folder targets;
|
||||
// discarding doesn't copy).
|
||||
const wantCopy = e.altKey && item.id.startsWith('folder-')
|
||||
e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move'
|
||||
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
||||
}
|
||||
} : undefined}
|
||||
onDragLeave={acceptsDrop ? (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
if (dropTargetId === item.id) setDropTargetId(null)
|
||||
}
|
||||
} : undefined}
|
||||
onDrop={acceptsDrop ? (e) => {
|
||||
e.preventDefault()
|
||||
setDropTargetId(null)
|
||||
const ids = readDragIds(e)
|
||||
if (ids) handleDrop(item.id, ids, e.altKey)
|
||||
} : undefined}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpanded(item.id)
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-surface-offset"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
|
||||
{/* Item Icon */}
|
||||
{item.icon && (
|
||||
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label (or inline rename input for folder rows) */}
|
||||
{renamingId === item.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => {
|
||||
const next = renameDraft.trim()
|
||||
const id = item.id.slice('folder-'.length)
|
||||
if (next && next !== item.label) {
|
||||
renameMutation.mutate({ id, name: next })
|
||||
}
|
||||
setRenamingId(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenamingId(null)
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
)}
|
||||
|
||||
{/* Count Badge — fixed-width slot so counts line up in a column
|
||||
* across rows regardless of digit count. */}
|
||||
{item.count !== undefined && item.count > 0 ? (
|
||||
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* Folder kebab menu — only on folder rows. Hidden (display:none)
|
||||
* until hover so it reserves NO width in the resting state and
|
||||
* the count column stays aligned across folder + non-folder
|
||||
* rows. On hover it appears to the right, pushing the count
|
||||
* left to make room. */}
|
||||
{item.id.startsWith('folder-') &&
|
||||
(() => {
|
||||
const folderId = item.id.slice('folder-'.length)
|
||||
const isMenuOpen = openMenuId === item.id
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex-shrink-0',
|
||||
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenMenuId(isMenuOpen ? null : item.id)
|
||||
}}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
title="More actions"
|
||||
aria-label="More folder actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
|
||||
>
|
||||
<FolderMenuItem
|
||||
icon={<FolderPlus className="h-3.5 w-3.5" />}
|
||||
label="New sub-folder"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setCreatingUnder(folderId)
|
||||
setCreateDraft('')
|
||||
// Make sure the parent is expanded so the new
|
||||
// input is visible.
|
||||
if (!expandedItems.has(item.id)) {
|
||||
toggleExpanded(item.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FolderMenuItem
|
||||
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||
label="Rename"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setRenamingId(item.id)
|
||||
setRenameDraft(item.label)
|
||||
}}
|
||||
/>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<FolderMenuItem
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
label="Delete folder…"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setDeletingFolder({
|
||||
id: folderId,
|
||||
name: item.label,
|
||||
photoCount: item.count,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Inline "create new sub-folder" input. Renders just below the
|
||||
* parent row when its create state is active. */}
|
||||
{item.id.startsWith('folder-') &&
|
||||
creatingUnder === item.id.slice('folder-'.length) && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1"
|
||||
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
|
||||
>
|
||||
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={createDraft}
|
||||
placeholder="New folder name"
|
||||
onChange={(e) => setCreateDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const name = createDraft.trim()
|
||||
if (name) {
|
||||
createFolderMutation.mutate({
|
||||
parentId: item.id.slice('folder-'.length),
|
||||
name,
|
||||
})
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setCreatingUnder(null)
|
||||
setCreateDraft('')
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Don't auto-commit on blur — empty/escaped renames
|
||||
// close the input but don't fire the request.
|
||||
if (!createFolderMutation.isPending) {
|
||||
setCreatingUnder(null)
|
||||
setCreateDraft('')
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Children */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
{/* Tree View */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{libraryTree.map((item) => renderTreeItem(item))}
|
||||
<HeapsPanel />
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
{folderTree.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
onClick={handleScanAll}
|
||||
disabled={isScanning}
|
||||
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx('h-4 w-4', isScanning && 'animate-spin')} />
|
||||
{isScanning ? 'Scanning...' : 'Scan all folders'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteFolderDialog
|
||||
isOpen={!!deletingFolder}
|
||||
folderName={deletingFolder?.name ?? ''}
|
||||
photoCount={deletingFolder?.photoCount}
|
||||
onClose={() => setDeletingFolder(null)}
|
||||
onConfirm={(mode) => {
|
||||
if (deletingFolder) {
|
||||
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderMenuItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
destructive = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
destructive?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
|
||||
destructive
|
||||
? 'text-reject hover:bg-reject/10'
|
||||
: 'text-text hover:bg-surface-2'
|
||||
)}
|
||||
>
|
||||
<span className="text-text-muted">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import {
|
||||
photos as photosApi,
|
||||
heaps as heapsApi,
|
||||
tags as tagsApi,
|
||||
} from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
|
||||
/**
|
||||
* Right-hand details panel.
|
||||
* - 1 photo selected → delegates to PhotoInfoPanel for the full editor.
|
||||
* - 2+ photos selected → renders a slim bulk-action panel that fans out
|
||||
* rating / color / discard / pick across the entire selection.
|
||||
*/
|
||||
export function RightSidebar() {
|
||||
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidatePhotoQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
}
|
||||
|
||||
const bulkRatingMutation = useMutation({
|
||||
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
||||
photosApi.bulkSetRating(ids, rating),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
const bulkColorMutation = useMutation({
|
||||
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
|
||||
photosApi.bulkSetColor(ids, color),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
const bulkDiscardMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
|
||||
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
||||
// query so the FilterBar / sidebar tag counts stay fresh.
|
||||
const invalidateTagsAndPhotos = () => {
|
||||
invalidatePhotoQueries()
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
}
|
||||
const bulkAddTagsMutation = useMutation({
|
||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||
photosApi.bulkAddTags(ids, tagIds),
|
||||
onSuccess: (data) => {
|
||||
const added = data?.added ?? 0
|
||||
toast.success(
|
||||
'Tags added',
|
||||
`${added} new link${added === 1 ? '' : 's'}`
|
||||
)
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tags failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
const bulkRemoveTagsMutation = useMutation({
|
||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||
photosApi.bulkRemoveTags(ids, tagIds),
|
||||
onSuccess: (data) => {
|
||||
const removed = data?.removed ?? 0
|
||||
toast.success(
|
||||
'Tags removed',
|
||||
`${removed} link${removed === 1 ? '' : 's'} removed`
|
||||
)
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Remove tags failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Idempotent create-and-attach: lets the user type a brand-new tag
|
||||
// name and apply it to the whole selection in one click.
|
||||
const createAndAttachMutation = useMutation({
|
||||
mutationFn: async ({ name, ids }: { name: string; ids: string[] }) => {
|
||||
const created = await tagsApi.create(name)
|
||||
return photosApi.bulkAddTags(ids, [created.id])
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Tag created and applied')
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Create tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// Active heap membership for the bulk Pick toggle.
|
||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
const heapMutation = useMutation({
|
||||
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
|
||||
if (!activeHeap || ids.length === 0) return Promise.resolve(null)
|
||||
return remove
|
||||
? heapsApi.removePhotos(activeHeap.id, ids)
|
||||
: heapsApi.addPhotos(activeHeap.id, ids)
|
||||
},
|
||||
onMutate: ({ ids, remove }) => {
|
||||
if (!activeHeap || ids.length === 0) return { previous: undefined }
|
||||
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||
const previous = queryClient.getQueryData<string[]>(key)
|
||||
const set = new Set(previous ?? [])
|
||||
if (remove) ids.forEach((id) => set.delete(id))
|
||||
else ids.forEach((id) => set.add(id))
|
||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||
return { previous }
|
||||
},
|
||||
onError: (e: any, _vars, ctx) => {
|
||||
if (activeHeap && ctx?.previous) {
|
||||
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||||
}
|
||||
toast.error('Heap update failed', e?.message || 'Unknown error')
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
if (activeHeap) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['heap-photo-ids', activeHeap.id],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (selectedPhotos.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<div className="text-text-muted">
|
||||
<Info className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">Select photos to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single-photo: full editor via PhotoInfoPanel ────────────────────
|
||||
if (selectedPhotos.length === 1) {
|
||||
const id = activePhotoId ?? selectedPhotos[0]
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<h2 className="text-sm font-semibold text-text">Metadata</h2>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear selection"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<PhotoInfoPanel photoId={id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Multi-photo: bulk action panel ──────────────────────────────────
|
||||
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text">
|
||||
{selectedPhotos.length} Photos Selected
|
||||
</h2>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b border-border p-4">
|
||||
<p className="text-xs text-text-muted">
|
||||
Rating, color, and flag apply to all {selectedPhotos.length} selected.
|
||||
</p>
|
||||
|
||||
{/* Bulk rating */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
|
||||
}
|
||||
className="p-0.5"
|
||||
title={`Set rating to ${value}`}
|
||||
>
|
||||
<Star className="h-5 w-5 text-text-muted hover:text-star" />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: 0 })
|
||||
}
|
||||
className="ml-1 rounded px-1 text-xs text-text-muted hover:text-text"
|
||||
title="Clear rating"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk color */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
bulkColorMutation.mutate({ ids: selectedPhotos, color: value })
|
||||
}
|
||||
className={clsx(
|
||||
'h-5 w-5 rounded-full opacity-80 ring-offset-2 ring-offset-surface transition-all hover:opacity-100',
|
||||
className
|
||||
)}
|
||||
title={value}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
bulkColorMutation.mutate({ ids: selectedPhotos, color: null })
|
||||
}
|
||||
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear color label"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk flag */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!activeHeap) return
|
||||
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
|
||||
}}
|
||||
disabled={!activeHeap || heapMutation.isPending}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
allMembers
|
||||
? 'bg-pick/20 text-pick'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
)}
|
||||
title={
|
||||
activeHeap
|
||||
? allMembers
|
||||
? `Remove all from "${activeHeap.name}"`
|
||||
: `Add all to "${activeHeap.name}"`
|
||||
: 'Set an active heap first'
|
||||
}
|
||||
>
|
||||
<ShoppingBasket className="h-3 w-3" />
|
||||
{allMembers ? 'Picked' : 'Pick'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
|
||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-1 text-sm text-text-muted transition-colors hover:bg-surface-offset"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk tags. Click an existing tag chip to apply it to the
|
||||
* whole selection; long-press / X icon to remove. The text
|
||||
* input adds an existing tag if it matches a name, or creates
|
||||
* a new tag and applies it. */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Tags</label>
|
||||
<BulkTagsEditor
|
||||
allTags={allTags}
|
||||
tagInput={tagInput}
|
||||
onTagInputChange={setTagInput}
|
||||
disabled={
|
||||
bulkAddTagsMutation.isPending ||
|
||||
bulkRemoveTagsMutation.isPending ||
|
||||
createAndAttachMutation.isPending
|
||||
}
|
||||
onApply={(tagId) =>
|
||||
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] })
|
||||
}
|
||||
onRemove={(tagId) =>
|
||||
bulkRemoveTagsMutation.mutate({
|
||||
ids: selectedPhotos,
|
||||
tagIds: [tagId],
|
||||
})
|
||||
}
|
||||
onCreate={(name) => {
|
||||
createAndAttachMutation.mutate({ name, ids: selectedPhotos })
|
||||
setTagInput('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BulkTagsEditorProps {
|
||||
allTags: { id: string; name: string; color: string | null }[]
|
||||
tagInput: string
|
||||
onTagInputChange: (value: string) => void
|
||||
disabled: boolean
|
||||
onApply: (tagId: string) => void
|
||||
onRemove: (tagId: string) => void
|
||||
onCreate: (name: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
|
||||
* single-photo TagsEditor we don't show "current tags" — there's no clean
|
||||
* single-photo notion of that across an arbitrary selection. Instead the
|
||||
* user picks an existing tag (apply to all) or types a new one (create
|
||||
* and apply to all).
|
||||
*/
|
||||
function BulkTagsEditor({
|
||||
allTags,
|
||||
tagInput,
|
||||
onTagInputChange,
|
||||
disabled,
|
||||
onApply,
|
||||
onRemove,
|
||||
onCreate,
|
||||
}: BulkTagsEditorProps) {
|
||||
const trimmed = tagInput.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
const filtered = trimmed
|
||||
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
|
||||
: allTags
|
||||
|
||||
const exactMatch = trimmed
|
||||
? allTags.find((t) => t.name.toLowerCase() === lower)
|
||||
: null
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trimmed || disabled) return
|
||||
if (exactMatch) {
|
||||
onApply(exactMatch.id)
|
||||
onTagInputChange('')
|
||||
} else {
|
||||
onCreate(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onTagInputChange('')
|
||||
}
|
||||
}}
|
||||
placeholder="Filter or create…"
|
||||
disabled={disabled}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{trimmed && !exactMatch && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create "{trimmed}" and apply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
|
||||
{filtered.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||
style={
|
||||
tag.color
|
||||
? { backgroundColor: `${tag.color}33`, color: tag.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onApply(tag.id)}
|
||||
disabled={disabled}
|
||||
className="hover:underline disabled:opacity-50"
|
||||
title={`Apply "${tag.name}" to selection`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(tag.id)}
|
||||
disabled={disabled}
|
||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
|
||||
title={`Remove "${tag.name}" from selection`}
|
||||
aria-label={`Remove ${tag.name} from selection`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-faint">No tags match</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ShoppingBasket } from 'lucide-react'
|
||||
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
||||
import muliLogo from '../../assets/muli-logo.png'
|
||||
|
||||
/**
|
||||
* Slim top bar — just the logo and the active-heap pill. The search input
|
||||
* lives in the FilterBar now (next to the rest of the filter controls).
|
||||
*/
|
||||
export function TopBar() {
|
||||
const { data: heapsList = [] } = useHeapsQuery()
|
||||
const activeHeap = heapsList.find((h) => h.is_active)
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
||||
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
||||
</div>
|
||||
{activeHeap && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
|
||||
title="Active heap — press P to add selected photos here"
|
||||
>
|
||||
<ShoppingBasket className="h-3 w-3" />
|
||||
{activeHeap.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2" />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
|
||||
interface PreviewFilmstripProps {
|
||||
photos: Photo[]
|
||||
currentIndex: number
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const CELL_SIZE = 72
|
||||
|
||||
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
||||
const activeRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [currentIndex])
|
||||
|
||||
return (
|
||||
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
|
||||
{photos.map((photo, index) => {
|
||||
const isActive = index === currentIndex
|
||||
return (
|
||||
<button
|
||||
key={photo.id}
|
||||
ref={isActive ? activeRef : null}
|
||||
onClick={() => onSelect(photo.id)}
|
||||
className={clsx(
|
||||
'shrink-0 overflow-hidden rounded-sm transition-all',
|
||||
'hover:opacity-100',
|
||||
isActive
|
||||
? 'ring-2 ring-primary opacity-100'
|
||||
: 'opacity-60'
|
||||
)}
|
||||
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
||||
title={photo.filename}
|
||||
>
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(photo.id, 'small')}
|
||||
alt={photo.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import {
|
||||
getPreviewImageSrc,
|
||||
getPreviewFallbackSrc,
|
||||
getVideoSrc,
|
||||
isVideo,
|
||||
} from './previewSrc'
|
||||
|
||||
interface PreviewImageProps {
|
||||
photo: Photo
|
||||
}
|
||||
|
||||
const MIN_SCALE = 1
|
||||
const MAX_SCALE = 8
|
||||
const WHEEL_STEP = 1.15
|
||||
|
||||
export function PreviewImage({ photo }: PreviewImageProps) {
|
||||
if (isVideo(photo)) {
|
||||
return <PreviewVideo photo={photo} />
|
||||
}
|
||||
return <PreviewStillImage photo={photo} />
|
||||
}
|
||||
|
||||
function PreviewVideo({ photo }: { photo: Photo }) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-black">
|
||||
<video
|
||||
key={photo.id}
|
||||
src={getVideoSrc(photo)}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewStillImage({ photo }: { photo: Photo }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
|
||||
// scale=1 means "fit to viewport". Anything >1 zooms in; we don't allow <1
|
||||
// because the fit size already fills the viewport.
|
||||
const [scale, setScale] = useState(1)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const dragStateRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
|
||||
// Reset everything when the photo changes.
|
||||
useEffect(() => {
|
||||
setLoaded(false)
|
||||
setUsingFallback(false)
|
||||
setScale(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
}, [photo.id])
|
||||
|
||||
const primarySrc = getPreviewImageSrc(photo)
|
||||
const fallbackSrc = getPreviewFallbackSrc(photo)
|
||||
const src = usingFallback ? fallbackSrc : primarySrc
|
||||
|
||||
const handleError = () => {
|
||||
if (!usingFallback && primarySrc !== fallbackSrc) {
|
||||
setUsingFallback(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Z key: toggle between fit (scale=1) and actual size (natural/displayed).
|
||||
// If we're already zoomed (manual wheel zoom), Z snaps back to fit.
|
||||
const toggleZoom = useCallback(() => {
|
||||
if (scale !== 1) {
|
||||
setScale(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
return
|
||||
}
|
||||
const img = imgRef.current
|
||||
if (!img) return
|
||||
const ratio = img.naturalWidth / img.clientWidth
|
||||
if (!isFinite(ratio) || ratio <= 1) return
|
||||
setScale(Math.min(ratio, MAX_SCALE))
|
||||
}, [scale])
|
||||
|
||||
useHotkeys(
|
||||
'z',
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
toggleZoom()
|
||||
},
|
||||
{ preventDefault: true },
|
||||
[toggleZoom]
|
||||
)
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP
|
||||
setScale((prev) => {
|
||||
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev * delta))
|
||||
// Snapping back to 1 also resets pan offset.
|
||||
if (next === 1) setOffset({ x: 0, y: 0 })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (scale === 1) return
|
||||
e.preventDefault()
|
||||
dragStateRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
ox: offset.x,
|
||||
oy: offset.y,
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const drag = dragStateRef.current
|
||||
if (!drag) return
|
||||
setOffset({
|
||||
x: drag.ox + (e.clientX - drag.x),
|
||||
y: drag.oy + (e.clientY - drag.y),
|
||||
})
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
dragStateRef.current = null
|
||||
}
|
||||
|
||||
const isZoomed = scale > 1
|
||||
const cursor = isZoomed
|
||||
? dragStateRef.current
|
||||
? 'grabbing'
|
||||
: 'grab'
|
||||
: 'zoom-in'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-1 select-none items-center justify-center overflow-hidden bg-black"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
style={{ cursor }}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
key={`${photo.id}-${usingFallback}`}
|
||||
src={src}
|
||||
alt={photo.filename}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={handleError}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
{!loaded && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-text-muted">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom indicator */}
|
||||
{isZoomed && (
|
||||
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
||||
{Math.round(scale * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { X, Info } from 'lucide-react'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { PreviewImage } from './PreviewImage'
|
||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
|
||||
export function PreviewView() {
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
||||
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
|
||||
|
||||
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||
// it up by key (which broke when the key gained the filter params).
|
||||
const { data: rawPhotos = [] } = usePhotosQuery()
|
||||
|
||||
// Walk the timeline's visible-order sequence (published by Timeline
|
||||
// into the photo store), which respects tag-grouping and any other
|
||||
// grid-layout rearrangement. Falls back to the raw photos list when
|
||||
// the sequence isn't populated yet — relevant on a fresh page load
|
||||
// where the user opened preview before the timeline mounted.
|
||||
const photos: Photo[] = useMemo(() => {
|
||||
if (visiblePhotoIds.length === 0) return rawPhotos
|
||||
const byId = new Map(rawPhotos.map((p) => [p.id, p]))
|
||||
const out: Photo[] = []
|
||||
for (const id of visiblePhotoIds) {
|
||||
const p = byId.get(id)
|
||||
if (p) out.push(p)
|
||||
}
|
||||
return out
|
||||
}, [visiblePhotoIds, rawPhotos])
|
||||
|
||||
const currentIndex = activePhotoId
|
||||
? photos.findIndex((p) => p.id === activePhotoId)
|
||||
: 0
|
||||
const safeIndex = currentIndex < 0 ? 0 : currentIndex
|
||||
const currentPhoto: Photo | undefined = photos[safeIndex]
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (photos.length === 0) return
|
||||
const next = Math.max(0, safeIndex - 1)
|
||||
setActivePhoto(photos[next].id)
|
||||
}, [photos, safeIndex, setActivePhoto])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (photos.length === 0) return
|
||||
const next = Math.min(photos.length - 1, safeIndex + 1)
|
||||
setActivePhoto(photos[next].id)
|
||||
}, [photos, safeIndex, setActivePhoto])
|
||||
|
||||
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
||||
useHotkeys('escape', closePreview, { preventDefault: true })
|
||||
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
||||
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
|
||||
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||
|
||||
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
||||
// (browsers can't preload them via Image()) and skip when at the edges.
|
||||
useEffect(() => {
|
||||
const neighbors: Photo[] = []
|
||||
if (safeIndex > 0) neighbors.push(photos[safeIndex - 1])
|
||||
if (safeIndex < photos.length - 1) neighbors.push(photos[safeIndex + 1])
|
||||
for (const p of neighbors) {
|
||||
if (isVideo(p)) continue
|
||||
const img = new Image()
|
||||
img.src = getPreviewImageSrc(p)
|
||||
}
|
||||
}, [safeIndex, photos])
|
||||
|
||||
// Focus trap: focus the preview container on mount, restore focus on
|
||||
// unmount. The container is keyboard-focusable (tabIndex=-1) so screen
|
||||
// readers and tab navigation stay scoped here.
|
||||
useEffect(() => {
|
||||
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
|
||||
containerRef.current?.focus()
|
||||
return () => {
|
||||
previouslyFocusedRef.current?.focus?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Trap Tab inside the dialog so users can't accidentally tab into the
|
||||
// hidden grid behind. Simple cycle implementation.
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
const root = containerRef.current
|
||||
if (!root) return
|
||||
const focusable = root.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault()
|
||||
root.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (e.shiftKey && active === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentPhoto) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo preview"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||
>
|
||||
<div>No photo to display</div>
|
||||
<button
|
||||
onClick={closePreview}
|
||||
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-0 z-40 flex bg-black outline-none"
|
||||
>
|
||||
{/* Main column — image + filmstrip */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
{/* Filename + counter */}
|
||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
|
||||
<div className="font-mono">{currentPhoto.filename}</div>
|
||||
<div className="text-text-muted">
|
||||
{safeIndex + 1} / {photos.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-right action buttons */}
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setInfoPanelOpen((v) => !v)}
|
||||
className={
|
||||
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' +
|
||||
(infoPanelOpen ? 'ring-2 ring-primary' : '')
|
||||
}
|
||||
title="Toggle info panel (I)"
|
||||
aria-label="Toggle info panel"
|
||||
aria-pressed={infoPanelOpen}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={closePreview}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
|
||||
title="Close (Esc)"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PreviewImage photo={currentPhoto} />
|
||||
|
||||
<PreviewFilmstrip
|
||||
photos={photos}
|
||||
currentIndex={safeIndex}
|
||||
onSelect={setActivePhoto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right info panel — slides in/out, mirrors the grid right sidebar
|
||||
* but lives inside the preview overlay so it isn't covered by it. */}
|
||||
{infoPanelOpen && (
|
||||
<aside className="w-80 shrink-0 overflow-hidden border-l border-border bg-surface">
|
||||
<PhotoInfoPanel photoId={currentPhoto.id} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.mkv', '.m4v']
|
||||
|
||||
export function isVideo(photo: Photo): boolean {
|
||||
if (photo.media_type === 'video') return true
|
||||
const lower = photo.filepath.toLowerCase()
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best display URL for a still photo in the preview view.
|
||||
*
|
||||
* Always uses the /proxy endpoint, which the backend resolves to:
|
||||
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF)
|
||||
* - a transcoded full-res WebP for RAW/HEIC/TIFF (cached on first hit)
|
||||
*
|
||||
* Videos go through `getVideoSrc` instead and use /original directly.
|
||||
*/
|
||||
export function getPreviewImageSrc(photo: Photo): string {
|
||||
return photosApi.getProxyUrl(photo.id)
|
||||
}
|
||||
|
||||
/** Fallback used when the proxy endpoint fails or 404s — shows the 1280px
|
||||
* large thumbnail so the user still sees something. */
|
||||
export function getPreviewFallbackSrc(photo: Photo): string {
|
||||
return photosApi.getThumbnailUrl(photo.id, 'large')
|
||||
}
|
||||
|
||||
export function getVideoSrc(photo: Photo): string {
|
||||
return photosApi.getOriginalUrl(photo.id)
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
MapPin,
|
||||
Camera,
|
||||
Aperture,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ShoppingBasket,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
photos as photosApi,
|
||||
heaps as heapsApi,
|
||||
tags as tagsApi,
|
||||
type Tag,
|
||||
} from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import {
|
||||
COLOR_LABEL_OPTIONS,
|
||||
type ColorLabel,
|
||||
} from '../../constants/colorLabels'
|
||||
|
||||
interface PhotoTagSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
interface PhotoDetails {
|
||||
id: string
|
||||
filename: string
|
||||
filepath: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
file_size: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
is_discarded: boolean
|
||||
user_title: string | null
|
||||
user_notes: string | null
|
||||
color_label: string | null
|
||||
exif_json: string | null
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
|
||||
interface ExifData {
|
||||
Make?: string
|
||||
Model?: string
|
||||
LensModel?: string
|
||||
Lens?: string
|
||||
ISO?: number | string
|
||||
FNumber?: number | string
|
||||
ApertureValue?: number | string
|
||||
ExposureTime?: string
|
||||
ShutterSpeedValue?: string
|
||||
FocalLength?: string
|
||||
FocalLengthIn35mmFormat?: string
|
||||
GPSLatitude?: number | string
|
||||
GPSLongitude?: number | string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null): string {
|
||||
if (bytes == null) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function formatExifValue(v: unknown): string {
|
||||
if (v == null || v === '') return '—'
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function pickFirst(exif: ExifData, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = exif[k]
|
||||
if (v != null && v !== '') return String(v)
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
function parseExif(json: string | null): ExifData {
|
||||
if (!json) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
interface PhotoInfoPanelProps {
|
||||
/** The photo to show metadata for. Drives an on-demand detail fetch. */
|
||||
photoId: string
|
||||
/** When true, the editable text fields (filename, title, notes) render
|
||||
* with a darker theme to read against a black preview backdrop. */
|
||||
darkTheme?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable single-photo metadata + edit panel. Used by both the grid
|
||||
* RightSidebar (when one photo is selected) and the PreviewView's optional
|
||||
* info overlay. Self-contained — owns its own queries and mutations.
|
||||
*/
|
||||
export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['basic', 'camera', 'location', 'tags'])
|
||||
)
|
||||
const toggleSection = (section: string) => {
|
||||
const next = new Set(expandedSections)
|
||||
if (next.has(section)) next.delete(section)
|
||||
else next.add(section)
|
||||
setExpandedSections(next)
|
||||
}
|
||||
|
||||
// Fetch the photo's full record (with EXIF) on demand.
|
||||
const { data: photo } = useQuery<PhotoDetails>({
|
||||
queryKey: ['photo', photoId],
|
||||
queryFn: () => photosApi.get(photoId),
|
||||
enabled: !!photoId,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Mutation for any patchable field. Invalidates both the photo detail
|
||||
// cache and the timeline list so the grid reflects the change too.
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
filename?: string
|
||||
rating?: number
|
||||
is_discarded?: boolean
|
||||
user_title?: string | null
|
||||
user_notes?: string | null
|
||||
color_label?: string | null
|
||||
}) => photosApi.update(photoId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Active heap membership for the Pick toggle button.
|
||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
const isInActiveHeap = activeHeapMembers.has(photoId)
|
||||
|
||||
const heapMutation = useMutation({
|
||||
mutationFn: ({ remove }: { remove: boolean }) => {
|
||||
if (!activeHeap) return Promise.resolve(null)
|
||||
return remove
|
||||
? heapsApi.removePhotos(activeHeap.id, [photoId])
|
||||
: heapsApi.addPhotos(activeHeap.id, [photoId])
|
||||
},
|
||||
onMutate: ({ remove }) => {
|
||||
if (!activeHeap) return { previous: undefined }
|
||||
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||
const previous = queryClient.getQueryData<string[]>(key)
|
||||
const set = new Set(previous ?? [])
|
||||
if (remove) set.delete(photoId)
|
||||
else set.add(photoId)
|
||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||
return { previous }
|
||||
},
|
||||
onError: (_e, _vars, ctx) => {
|
||||
if (activeHeap && ctx?.previous) {
|
||||
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
if (activeHeap) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['heap-photo-ids', activeHeap.id],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tags state + mutations ──────────────────────────────────────────
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const invalidateTagsAndPhoto = () => {
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
}
|
||||
|
||||
const addTagMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const created = await tagsApi.create(name)
|
||||
await tagsApi.addToPhoto(photoId, [created.id])
|
||||
return created
|
||||
},
|
||||
onSuccess: () => invalidateTagsAndPhoto(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const attachExistingTagMutation = useMutation({
|
||||
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
|
||||
onSuccess: () => invalidateTagsAndPhoto(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const removeTagMutation = useMutation({
|
||||
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
|
||||
onSuccess: () => invalidateTagsAndPhoto(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Remove tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Local drafts for the text fields. Mirror the server value but stay
|
||||
// independent while typing so we don't fight focus or clobber edits.
|
||||
const [filenameDraft, setFilenameDraft] = useState('')
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [notesDraft, setNotesDraft] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFilenameDraft(photo?.filename ?? '')
|
||||
setTitleDraft(photo?.user_title ?? '')
|
||||
setNotesDraft(photo?.user_notes ?? '')
|
||||
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
|
||||
|
||||
const commitFilename = () => {
|
||||
const next = filenameDraft.trim()
|
||||
const current = photo?.filename ?? ''
|
||||
if (!next || next === current) {
|
||||
setFilenameDraft(current)
|
||||
return
|
||||
}
|
||||
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
|
||||
toast.error('Invalid filename', 'No path separators allowed')
|
||||
setFilenameDraft(current)
|
||||
return
|
||||
}
|
||||
updateMutation.mutate(
|
||||
{ filename: next },
|
||||
{
|
||||
onError: (e: any) => {
|
||||
toast.error(
|
||||
'Rename failed',
|
||||
e?.response?.data?.detail || e.message || 'Unknown error'
|
||||
)
|
||||
setFilenameDraft(current)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const commitTitle = () => {
|
||||
const next = titleDraft.trim()
|
||||
const current = photo?.user_title ?? ''
|
||||
if (next === current) return
|
||||
updateMutation.mutate({ user_title: next || null })
|
||||
}
|
||||
|
||||
const commitNotes = () => {
|
||||
const next = notesDraft
|
||||
const current = photo?.user_notes ?? ''
|
||||
if (next === current) return
|
||||
updateMutation.mutate({ user_notes: next || null })
|
||||
}
|
||||
|
||||
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||
|
||||
if (!photo) {
|
||||
return <div className="p-4 text-xs text-text-muted">Loading…</div>
|
||||
}
|
||||
|
||||
const rating = photo.rating ?? 0
|
||||
const isDiscarded = photo.is_discarded ?? false
|
||||
const colorLabel = (photo.color_label ?? null) as ColorLabel | null
|
||||
|
||||
// Single themable input class so the same component reads against either
|
||||
// the surface (grid sidebar) or a darker preview overlay.
|
||||
const inputClass = clsx(
|
||||
'w-full rounded border px-2 py-1 text-sm focus:outline-none',
|
||||
darkTheme
|
||||
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
|
||||
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
|
||||
)
|
||||
const monoInputClass = clsx(
|
||||
'w-full rounded border px-2 py-1 font-mono text-xs focus:outline-none',
|
||||
darkTheme
|
||||
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
|
||||
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Edit fields */}
|
||||
<div className="space-y-3 border-b border-border p-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filenameDraft}
|
||||
onChange={(e) => setFilenameDraft(e.target.value)}
|
||||
onBlur={commitFilename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setFilenameDraft(photo.filename ?? '')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setTitleDraft(photo.user_title ?? '')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="No title"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
onBlur={commitNotes}
|
||||
placeholder="Add notes…"
|
||||
rows={3}
|
||||
className={clsx(inputClass, 'resize-none')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
||||
}
|
||||
className="p-0.5"
|
||||
title={`Set rating to ${value}`}
|
||||
>
|
||||
<Star
|
||||
className={clsx(
|
||||
'h-5 w-5 transition-colors',
|
||||
value <= rating
|
||||
? 'fill-star text-star'
|
||||
: 'text-text-muted hover:text-star'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color label */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
||||
const active = colorLabel === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({ color_label: active ? null : value })
|
||||
}
|
||||
className={clsx(
|
||||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||||
className,
|
||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title={value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{colorLabel && (
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ color_label: null })}
|
||||
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear color label"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flag — Pick + Discard */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!activeHeap) return
|
||||
heapMutation.mutate({ remove: isInActiveHeap })
|
||||
}}
|
||||
disabled={!activeHeap || heapMutation.isPending}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isInActiveHeap
|
||||
? 'bg-pick/20 text-pick'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
)}
|
||||
title={
|
||||
activeHeap
|
||||
? isInActiveHeap
|
||||
? `Remove from "${activeHeap.name}"`
|
||||
: `Add to "${activeHeap.name}"`
|
||||
: 'Set an active heap first'
|
||||
}
|
||||
>
|
||||
<ShoppingBasket className="h-3 w-3" />
|
||||
{isInActiveHeap ? 'Picked' : 'Pick'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
isDiscarded
|
||||
? 'bg-reject/20 text-reject'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only metadata sections */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Section
|
||||
title="Basic Info"
|
||||
expanded={expandedSections.has('basic')}
|
||||
onToggle={() => toggleSection('basic')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||||
<Field
|
||||
label="Dimensions"
|
||||
value={
|
||||
photo.width && photo.height
|
||||
? `${photo.width} × ${photo.height}`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Date Taken"
|
||||
value={
|
||||
photo.taken_at
|
||||
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Camera"
|
||||
expanded={expandedSections.has('camera')}
|
||||
onToggle={() => toggleSection('camera')}
|
||||
>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-3 w-3 text-text-muted" />
|
||||
<span className="text-text">
|
||||
{pickFirst(exif, 'Make', 'Model') === '—'
|
||||
? '—'
|
||||
: `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Aperture className="h-3 w-3 text-text-muted" />
|
||||
<span className="text-text">
|
||||
{pickFirst(exif, 'LensModel', 'Lens')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<Field label="ISO" value={formatExifValue(exif.ISO)} />
|
||||
<Field
|
||||
label="Aperture"
|
||||
value={
|
||||
exif.FNumber
|
||||
? `f/${exif.FNumber}`
|
||||
: pickFirst(exif, 'ApertureValue')
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Shutter"
|
||||
value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
|
||||
/>
|
||||
<Field
|
||||
label="Focal"
|
||||
value={pickFirst(
|
||||
exif,
|
||||
'FocalLength',
|
||||
'FocalLengthIn35mmFormat'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Location"
|
||||
expanded={expandedSections.has('location')}
|
||||
onToggle={() => toggleSection('location')}
|
||||
>
|
||||
{exif.GPSLatitude && exif.GPSLongitude ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<MapPin className="h-3 w-3 text-text-muted" />
|
||||
<span className="font-mono text-text">
|
||||
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-muted">No GPS data</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Tags"
|
||||
expanded={expandedSections.has('tags')}
|
||||
onToggle={() => toggleSection('tags')}
|
||||
>
|
||||
<TagsEditor
|
||||
photoTags={photo.tags ?? []}
|
||||
allTags={allTags}
|
||||
tagInput={tagInput}
|
||||
onTagInputChange={setTagInput}
|
||||
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
|
||||
onCreateAndAttach={(name) => {
|
||||
addTagMutation.mutate(name)
|
||||
setTagInput('')
|
||||
}}
|
||||
onRemove={(id) => removeTagMutation.mutate(id)}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
<span className="font-medium text-text">{title}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && <div className="px-4 pb-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TagsEditorProps {
|
||||
photoTags: PhotoTagSummary[]
|
||||
allTags: Tag[]
|
||||
tagInput: string
|
||||
onTagInputChange: (value: string) => void
|
||||
onAttachExisting: (id: string) => void
|
||||
onCreateAndAttach: (name: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function TagsEditor({
|
||||
photoTags,
|
||||
allTags,
|
||||
tagInput,
|
||||
onTagInputChange,
|
||||
onAttachExisting,
|
||||
onCreateAndAttach,
|
||||
onRemove,
|
||||
}: TagsEditorProps) {
|
||||
const trimmed = tagInput.trim()
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
const photoTagIds = new Set(photoTags.map((t) => t.id))
|
||||
|
||||
const suggestions = trimmed
|
||||
? allTags
|
||||
.filter(
|
||||
(t) =>
|
||||
!photoTagIds.has(t.id) &&
|
||||
t.name.toLowerCase().includes(lowerTrimmed)
|
||||
)
|
||||
.slice(0, 6)
|
||||
: []
|
||||
|
||||
const exactMatch = trimmed
|
||||
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
|
||||
: null
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trimmed) return
|
||||
if (exactMatch) {
|
||||
if (!photoTagIds.has(exactMatch.id)) {
|
||||
onAttachExisting(exactMatch.id)
|
||||
}
|
||||
onTagInputChange('')
|
||||
} else {
|
||||
onCreateAndAttach(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{photoTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{photoTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||
style={
|
||||
tag.color
|
||||
? { backgroundColor: `${tag.color}33`, color: tag.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => onRemove(tag.id)}
|
||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
|
||||
title="Remove tag"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-faint">No tags</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onTagInputChange('')
|
||||
}
|
||||
}}
|
||||
placeholder="Add tag…"
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mt-1 rounded border border-border bg-bg shadow-md">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
onAttachExisting(s.id)
|
||||
onTagInputChange('')
|
||||
}}
|
||||
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{trimmed && !exactMatch && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
|
||||
>
|
||||
+ Create "{trimmed}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-text-muted">{label}:</span>
|
||||
<p className="break-words text-text">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
|
||||
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||
|
||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||
// first hit often 404s. Try a few times with backoff before giving up.
|
||||
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
size: number
|
||||
isSelected: boolean
|
||||
/** True when the photo belongs to the currently active heap. */
|
||||
isInActiveHeap?: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
onDoubleClick?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function PhotoThumbnail({
|
||||
photo,
|
||||
size,
|
||||
isSelected,
|
||||
isInActiveHeap = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: PhotoThumbnailProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
const retryTimerRef = useRef<number | null>(null)
|
||||
|
||||
// Cache-bust on retry so the browser actually re-requests instead of
|
||||
// serving the cached 404.
|
||||
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
|
||||
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
|
||||
|
||||
// Square cells (Lightroom-style grid). Variable-aspect cells previously
|
||||
// overflowed their row because TanStack Virtual estimates row height as a
|
||||
// single fixed value — portraits in a landscape row would overlap the row
|
||||
// below. With object-cover the image still fills the cell, just cropped.
|
||||
const displayHeight = size
|
||||
|
||||
const clearRetryTimer = () => {
|
||||
if (retryTimerRef.current !== null) {
|
||||
window.clearTimeout(retryTimerRef.current)
|
||||
retryTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoaded(true)
|
||||
setIsRetrying(false)
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
// Schedule next auto-retry if attempts remain.
|
||||
const nextDelay = AUTO_RETRY_DELAYS[retryCount]
|
||||
if (nextDelay !== undefined) {
|
||||
setIsRetrying(true)
|
||||
clearRetryTimer()
|
||||
retryTimerRef.current = window.setTimeout(() => {
|
||||
retryTimerRef.current = null
|
||||
setRetryCount(prev => prev + 1)
|
||||
}, nextDelay)
|
||||
} else {
|
||||
setImageError(true)
|
||||
setIsRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualRetry = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent selection when clicking retry
|
||||
clearRetryTimer()
|
||||
setRetryCount(prev => prev + 1)
|
||||
setImageError(false)
|
||||
setImageLoaded(false)
|
||||
setIsRetrying(true)
|
||||
}, [])
|
||||
|
||||
// Reset state when photo changes (component is reused across rows when virtualized)
|
||||
useEffect(() => {
|
||||
clearRetryTimer()
|
||||
setImageError(false)
|
||||
setImageLoaded(false)
|
||||
setRetryCount(0)
|
||||
setIsRetrying(false)
|
||||
}, [photo.id])
|
||||
|
||||
// Clear pending timer on unmount to avoid setState-after-unmount.
|
||||
useEffect(() => {
|
||||
return () => clearRetryTimer()
|
||||
}, [])
|
||||
|
||||
// Build the drag payload at fire time so multi-selection drags carry the
|
||||
// current selection. If the dragged photo isn't part of the selection,
|
||||
// drag just that one photo (matches Finder semantics).
|
||||
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const state = usePhotoStore.getState()
|
||||
const ids =
|
||||
state.selectedPhotos.includes(photo.id) && state.selectedPhotos.length > 0
|
||||
? state.selectedPhotos
|
||||
: [photo.id]
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData(PHOTO_DRAG_MIME, JSON.stringify(ids))
|
||||
// A plain text fallback so the OS shows something sensible if the user
|
||||
// drops outside the app.
|
||||
e.dataTransfer.setData('text/plain', `${ids.length} photo${ids.length > 1 ? 's' : ''}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
||||
'hover:ring-2 hover:ring-primary/50',
|
||||
isSelected && 'ring-2 ring-primary shadow-lg',
|
||||
!imageLoaded && 'bg-surface animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: displayHeight,
|
||||
}}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
|
||||
>
|
||||
{/* Thumbnail Image */}
|
||||
{!imageError ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={photo.filename}
|
||||
className={clsx(
|
||||
'h-full w-full object-cover transition-opacity duration-200',
|
||||
imageLoaded ? 'opacity-100' : 'opacity-0',
|
||||
// Discarded photos fade out + desaturate so the trash section
|
||||
// reads as a trash section, not just another grid view.
|
||||
photo.is_discarded && 'opacity-50 grayscale'
|
||||
)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Loading indicator */}
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface">
|
||||
<div className="text-text-muted">
|
||||
{isRetrying ? (
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-1" />
|
||||
<div className="text-xs">Retrying...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-8 w-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
|
||||
<div className="text-center text-xs">
|
||||
<button
|
||||
onClick={handleManualRetry}
|
||||
className="p-2 hover:bg-surface-light rounded transition-colors"
|
||||
title="Retry loading thumbnail"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5 mb-1" />
|
||||
</button>
|
||||
<div>Unable to load</div>
|
||||
<div className="mt-1 font-mono text-[10px] px-2 break-all">{photo.filename}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating Stars */}
|
||||
{photo.rating > 0 && (
|
||||
<div className="absolute bottom-1 left-1 flex gap-0.5">
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className="h-3 w-3 fill-star text-star"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flag Indicators */}
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
||||
{isInActiveHeap && (
|
||||
<div
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-pick text-white shadow-md"
|
||||
title="In active heap"
|
||||
>
|
||||
<ShoppingBasket className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
{photo.is_duplicate && (
|
||||
<div
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white shadow-md"
|
||||
title="Duplicate (matches another photo's hash)"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
{photo.is_discarded && (
|
||||
<div
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-reject text-white shadow-md"
|
||||
title="Discarded"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Type Badge for RAW/Video */}
|
||||
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
|
||||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
|
||||
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
// Layout constants for the grid + grouped headers.
|
||||
const THUMBNAIL_SIZE = 200
|
||||
const GAP = 4
|
||||
const PADDING = 16
|
||||
const HEADER_HEIGHT = 36
|
||||
|
||||
interface PhotoCell {
|
||||
photo: Photo
|
||||
globalIndex: number
|
||||
}
|
||||
|
||||
type TimelineItem =
|
||||
| { type: 'header'; key: string; label: string; height: number }
|
||||
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
|
||||
|
||||
/**
|
||||
* Build the flat header|row item array the virtualizer renders.
|
||||
*
|
||||
* Three modes:
|
||||
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket
|
||||
* for photos with no tags). A photo with N tags appears in N buckets.
|
||||
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
|
||||
* - otherwise: one un-headered stream.
|
||||
*/
|
||||
function buildItems(
|
||||
photos: Photo[],
|
||||
columns: number,
|
||||
sortBy: string,
|
||||
groupBy: 'date' | 'tag'
|
||||
): TimelineItem[] {
|
||||
if (photos.length === 0) return []
|
||||
|
||||
const items: TimelineItem[] = []
|
||||
|
||||
// Helper: split a flat array of cells into rows of `columns` cells.
|
||||
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
|
||||
for (let i = 0; i < cells.length; i += columns) {
|
||||
const slice = cells.slice(i, i + columns)
|
||||
items.push({
|
||||
type: 'row',
|
||||
key: `${groupKey}::row::${i}`,
|
||||
cells: slice,
|
||||
height: THUMBNAIL_SIZE + GAP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tag grouping ──────────────────────────────────────────────────────
|
||||
if (groupBy === 'tag') {
|
||||
// Bucket by tag name. A photo with multiple tags lands in multiple
|
||||
// buckets. Photos with no tags go into "Untagged".
|
||||
const tagBuckets = new Map<string, PhotoCell[]>()
|
||||
const untagged: PhotoCell[] = []
|
||||
|
||||
photos.forEach((photo, globalIndex) => {
|
||||
const cell: PhotoCell = { photo, globalIndex }
|
||||
const tags = photo.tags ?? []
|
||||
if (tags.length === 0) {
|
||||
untagged.push(cell)
|
||||
} else {
|
||||
for (const t of tags) {
|
||||
const arr = tagBuckets.get(t.name) ?? []
|
||||
arr.push(cell)
|
||||
tagBuckets.set(t.name, arr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Sort tag groups alphabetically; Untagged goes at the end.
|
||||
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
)
|
||||
|
||||
let bucketIndex = 0
|
||||
for (const name of sortedTagNames) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `tag::${bucketIndex}::${name}`,
|
||||
label: name,
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
|
||||
bucketIndex++
|
||||
}
|
||||
if (untagged.length > 0) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `tag::${bucketIndex}::__untagged`,
|
||||
label: 'Untagged',
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ── Date grouping (existing) ──────────────────────────────────────────
|
||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||
|
||||
if (!isDateSort) {
|
||||
// No grouping — one row stream.
|
||||
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
|
||||
photo,
|
||||
globalIndex,
|
||||
}))
|
||||
pushRowsForGroup('all', cells)
|
||||
return items
|
||||
}
|
||||
|
||||
// Walk photos in order, breaking into groups whenever the month label changes.
|
||||
let currentLabel: string | null = null
|
||||
let bucket: PhotoCell[] = []
|
||||
let bucketIndex = 0
|
||||
|
||||
const flushBucket = () => {
|
||||
if (bucket.length === 0 || currentLabel === null) return
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `header::${bucketIndex}::${currentLabel}`,
|
||||
label: currentLabel,
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket)
|
||||
bucketIndex++
|
||||
bucket = []
|
||||
}
|
||||
|
||||
photos.forEach((photo, globalIndex) => {
|
||||
const dateStr =
|
||||
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
|
||||
let label: string
|
||||
if (dateStr) {
|
||||
try {
|
||||
label = format(parseISO(dateStr), 'MMMM yyyy')
|
||||
} catch {
|
||||
label = 'Unknown date'
|
||||
}
|
||||
} else {
|
||||
label = 'Unknown date'
|
||||
}
|
||||
if (label !== currentLabel) {
|
||||
flushBucket()
|
||||
currentLabel = label
|
||||
}
|
||||
bucket.push({ photo, globalIndex })
|
||||
})
|
||||
flushBucket()
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function Timeline() {
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const {
|
||||
selectedPhotos,
|
||||
activePhotoId,
|
||||
lastSelectedIndex,
|
||||
rangeStartIndex,
|
||||
selectPhoto,
|
||||
togglePhotoSelection,
|
||||
clearSelection,
|
||||
openPreview,
|
||||
} = usePhotoStore()
|
||||
// Pulled via a focused selector so the publisher subscription doesn't
|
||||
// re-render Timeline on every unrelated photo store change.
|
||||
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
|
||||
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
|
||||
// Calculate number of columns based on container width.
|
||||
const columns = useMemo(() => {
|
||||
if (containerWidth === 0) return 4
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP))
|
||||
)
|
||||
}, [containerWidth])
|
||||
|
||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||
// they share one cache entry, regardless of filter state.
|
||||
const { data: photos = [], isLoading } = usePhotosQuery()
|
||||
|
||||
// Membership in the active heap (for the basket affordance). Subscribed
|
||||
// once at this level so we don't have hundreds of thumbnails each
|
||||
// subscribing to the same query.
|
||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||
// photos. Date headers appear when sorted by a date field; tag headers
|
||||
// appear when groupBy === 'tag' (overrides date grouping).
|
||||
const items = useMemo(
|
||||
() => buildItems(photos, columns, sortBy, groupBy),
|
||||
[photos, columns, sortBy, groupBy]
|
||||
)
|
||||
|
||||
// Pre-computed offset of every header in the virtualizer's coordinate
|
||||
// space, used to drive the sticky-header overlay below.
|
||||
const headerOffsets = useMemo(() => {
|
||||
const result: { offset: number; label: string }[] = []
|
||||
let cumulative = 0
|
||||
for (const item of items) {
|
||||
if (item.type === 'header') {
|
||||
result.push({ offset: cumulative, label: item.label })
|
||||
}
|
||||
cumulative += item.height
|
||||
}
|
||||
return result
|
||||
}, [items])
|
||||
|
||||
// Range-selection helper. Operates on the global photos array, not on
|
||||
// virtualizer items.
|
||||
const selectRange = (endIndex: number) => {
|
||||
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
|
||||
const minIndex = Math.min(startIndex, endIndex)
|
||||
const maxIndex = Math.max(startIndex, endIndex)
|
||||
for (let i = minIndex; i <= maxIndex; i++) {
|
||||
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
|
||||
togglePhotoSelection(photos[i].id, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual scrolling setup with per-item heights.
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
// Re-measure when items change (column count, group structure).
|
||||
useEffect(() => {
|
||||
virtualizer.measure()
|
||||
}, [items, virtualizer])
|
||||
|
||||
// Track scroll position so we can show the current group label as a
|
||||
// pinned overlay at the top of the scroll container. The virtualizer's
|
||||
// items use transform translateY (so CSS position: sticky doesn't work
|
||||
// on the inline headers); the overlay sidesteps that by living outside
|
||||
// the virtualizer's positioned children.
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = parentRef.current
|
||||
if (!el) return
|
||||
const onScroll = () => setScrollTop(el.scrollTop)
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => el.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
// Find the latest header whose BOTTOM is above the viewport top. That's
|
||||
// the group whose natural in-grid header has scrolled out of view —
|
||||
// exactly the case where we want to pin the label as a sticky overlay.
|
||||
// If the natural header is still visible (scrolled but not yet past),
|
||||
// we return null and let the in-grid label do the work, avoiding the
|
||||
// duplicate-label flash.
|
||||
const stickyLabel = useMemo(() => {
|
||||
if (headerOffsets.length === 0) return null
|
||||
let current: string | null = null
|
||||
for (const h of headerOffsets) {
|
||||
if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
|
||||
else break
|
||||
}
|
||||
return current
|
||||
}, [headerOffsets, scrollTop])
|
||||
|
||||
// Measure container width on mount and resize.
|
||||
useEffect(() => {
|
||||
const measureWidth = () => {
|
||||
if (parentRef.current) {
|
||||
setContainerWidth(parentRef.current.clientWidth)
|
||||
}
|
||||
}
|
||||
measureWidth()
|
||||
window.addEventListener('resize', measureWidth)
|
||||
return () => window.removeEventListener('resize', measureWidth)
|
||||
}, [])
|
||||
|
||||
// Photo rows in visual order — drops the header items so navigation
|
||||
// walks the grid as the user sees it. Each row has cells of length
|
||||
// [1..columns], the last row of a group can be short, and a single
|
||||
// photo with multiple tags will appear in multiple rows.
|
||||
const photoRows = useMemo(
|
||||
() => items.filter((it): it is Extract<TimelineItem, { type: 'row' }> => it.type === 'row'),
|
||||
[items]
|
||||
)
|
||||
|
||||
// Publish the flat visible-order id sequence to the photo store so
|
||||
// PreviewView arrow nav (and the filmstrip) walks the same order the
|
||||
// user sees in the grid. Includes duplicates from tag grouping —
|
||||
// landing on the same photo's "second" appearance in the next tag
|
||||
// bucket is the right behavior in tag mode.
|
||||
useEffect(() => {
|
||||
const ids: string[] = []
|
||||
for (const row of photoRows) {
|
||||
for (const cell of row.cells) {
|
||||
ids.push(cell.photo.id)
|
||||
}
|
||||
}
|
||||
setVisiblePhotoIds(ids)
|
||||
}, [photoRows, setVisiblePhotoIds])
|
||||
|
||||
// Locate the active photo in the visual grid. Returns the FIRST
|
||||
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
||||
// can repeat a photo across groups. Returns null when there's no
|
||||
// active photo or it isn't currently rendered.
|
||||
const findActiveCell = (): { row: number; col: number } | null => {
|
||||
if (!activePhotoId) return null
|
||||
for (let r = 0; r < photoRows.length; r++) {
|
||||
const row = photoRows[r]
|
||||
const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
|
||||
if (c >= 0) return { row: r, col: c }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle keyboard shortcuts for photo navigation. Operates on the
|
||||
// grouped grid the user sees, so a half-full last row of a group
|
||||
// doesn't make ArrowDown skip into the wrong place.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (photoRows.length === 0) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||
return
|
||||
}
|
||||
|
||||
const move = (dr: number, dc: number) => {
|
||||
const current = findActiveCell() ?? { row: 0, col: -1 }
|
||||
let nextRow = current.row
|
||||
let nextCol = current.col + dc
|
||||
|
||||
if (dc !== 0) {
|
||||
// Wrap left/right across row boundaries.
|
||||
while (nextCol < 0 && nextRow > 0) {
|
||||
nextRow -= 1
|
||||
nextCol = photoRows[nextRow].cells.length - 1
|
||||
}
|
||||
while (
|
||||
nextRow < photoRows.length &&
|
||||
nextCol >= photoRows[nextRow].cells.length
|
||||
) {
|
||||
if (nextRow === photoRows.length - 1) {
|
||||
nextCol = photoRows[nextRow].cells.length - 1
|
||||
break
|
||||
}
|
||||
nextRow += 1
|
||||
nextCol = 0
|
||||
}
|
||||
if (nextCol < 0) nextCol = 0
|
||||
}
|
||||
|
||||
if (dr !== 0) {
|
||||
nextRow += dr
|
||||
if (nextRow < 0) nextRow = 0
|
||||
if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
|
||||
// Clamp the column to the destination row's actual width so
|
||||
// moving down into a half-full row lands on its last cell
|
||||
// instead of nothing.
|
||||
const rowLen = photoRows[nextRow].cells.length
|
||||
if (nextCol >= rowLen) nextCol = rowLen - 1
|
||||
if (nextCol < 0) nextCol = 0
|
||||
}
|
||||
|
||||
const dest = photoRows[nextRow]?.cells[nextCol]
|
||||
if (!dest) return
|
||||
if (e.shiftKey) {
|
||||
selectRange(dest.globalIndex)
|
||||
} else {
|
||||
selectPhoto(dest.photo.id, dest.globalIndex)
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
move(-1, 0)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
move(1, 0)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
move(0, -1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
move(0, 1)
|
||||
break
|
||||
case 'a':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
photos.forEach((photo, index) => {
|
||||
if (!selectedPhotos.includes(photo.id)) {
|
||||
togglePhotoSelection(photo.id, index)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
clearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [photoRows, photos, selectedPhotos, activePhotoId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-text-muted">Loading photos...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-text-muted">(╯°□°)╯︵ ┻━┻</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{/* Sticky group-header overlay. Lives outside the virtualizer's
|
||||
* positioned children so it isn't affected by translateY transforms.
|
||||
* Updates as the user scrolls past month boundaries. */}
|
||||
{stickyLabel && (
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b-2 border-border bg-bg/95 px-4 py-1.5 shadow-sm backdrop-blur">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||
{stickyLabel}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto bg-bg"
|
||||
style={{ padding: `${PADDING}px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = items[virtualItem.index]
|
||||
if (!item) return null
|
||||
|
||||
if (item.type === 'header') {
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
className="flex items-end pb-1"
|
||||
>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
|
||||
{item.label}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// row
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex" style={{ gap: `${GAP}px` }}>
|
||||
{item.cells.map(({ photo, globalIndex }) => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
size={THUMBNAIL_SIZE}
|
||||
isSelected={selectedPhotos.includes(photo.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||
selectRange(globalIndex)
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
togglePhotoSelection(photo.id, globalIndex)
|
||||
} else {
|
||||
selectPhoto(photo.id, globalIndex)
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => openPreview(photo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Single source of truth for the six Lightroom-style color labels.
|
||||
* Both filter UIs and edit UIs (FilterBar, PhotoInfoPanel, RightSidebar)
|
||||
* read from this list so dot colors and ordering stay consistent.
|
||||
*/
|
||||
|
||||
export type ColorLabel =
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
|
||||
export const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useHeapsQuery } from './useHeapsQuery'
|
||||
import { heaps as heapsApi } from '../services/api'
|
||||
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set()
|
||||
|
||||
/**
|
||||
* Returns the photo ids that belong to the active heap as a Set, plus the
|
||||
* active heap itself. Used by PhotoThumbnail to render the basket affordance
|
||||
* and by the P shortcut to decide between add vs remove.
|
||||
*
|
||||
* If no heap is active, the Set is empty (and shared across renders).
|
||||
*/
|
||||
export function useActiveHeapMembers(): {
|
||||
activeHeap: ReturnType<typeof useHeapsQuery>['data'] extends (infer T)[] | undefined
|
||||
? T | null
|
||||
: never
|
||||
memberIds: ReadonlySet<string>
|
||||
} {
|
||||
const { data: heaps } = useHeapsQuery()
|
||||
const activeHeap = heaps?.find((h) => h.is_active) ?? null
|
||||
|
||||
const { data: ids } = useQuery({
|
||||
queryKey: ['heap-photo-ids', activeHeap?.id],
|
||||
queryFn: () => heapsApi.photoIds(activeHeap!.id),
|
||||
enabled: !!activeHeap,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const memberIds = useMemo(
|
||||
() => (ids ? new Set(ids) : EMPTY_SET),
|
||||
[ids]
|
||||
)
|
||||
|
||||
return { activeHeap: activeHeap as any, memberIds }
|
||||
}
|
||||
|
||||
export const ACTIVE_HEAP_MEMBERS_QUERY_KEY_PREFIX = ['heap-photo-ids'] as const
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
useFilterStore,
|
||||
type FilterState,
|
||||
type MediaType,
|
||||
type ColorLabel,
|
||||
type FlagFilter,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from '../store/filterStore'
|
||||
|
||||
const ALLOWED_MEDIA: MediaType[] = ['photo', 'video', 'raw', 'heic']
|
||||
const ALLOWED_COLORS: ColorLabel[] = [
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'blue',
|
||||
'purple',
|
||||
]
|
||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded']
|
||||
const ALLOWED_SORT_FIELDS: SortField[] = [
|
||||
'taken_at',
|
||||
'added_at',
|
||||
'filename',
|
||||
'file_size',
|
||||
'rating',
|
||||
]
|
||||
const ALLOWED_SORT_ORDERS: SortOrder[] = ['asc', 'desc']
|
||||
|
||||
// What parseUrl returns: a partial filter state, plus the optional
|
||||
// section id (which lives on the store but isn't part of FilterState
|
||||
// itself). The hydrate action accepts this exact shape.
|
||||
type HydratePayload = Partial<FilterState> & { currentSection?: string }
|
||||
|
||||
function parseUrl(): HydratePayload {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const out: HydratePayload = {}
|
||||
|
||||
const q = sp.get('q')
|
||||
if (q) out.q = q
|
||||
|
||||
const df = sp.get('date_from')
|
||||
if (df) out.dateFrom = df
|
||||
|
||||
const dt = sp.get('date_to')
|
||||
if (dt) out.dateTo = dt
|
||||
|
||||
const mt = sp.get('media_type')
|
||||
if (mt) {
|
||||
const types = mt
|
||||
.split(',')
|
||||
.filter((t): t is MediaType => ALLOWED_MEDIA.includes(t as MediaType))
|
||||
if (types.length > 0) out.mediaTypes = types
|
||||
}
|
||||
|
||||
const rm = sp.get('rating_min')
|
||||
if (rm) {
|
||||
const n = parseInt(rm, 10)
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n
|
||||
}
|
||||
|
||||
const cl = sp.get('color_label')
|
||||
if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) {
|
||||
out.colorLabel = cl as ColorLabel
|
||||
}
|
||||
|
||||
const flag = sp.get('flag')
|
||||
if (flag && ALLOWED_FLAGS.includes(flag as FlagFilter)) {
|
||||
out.flag = flag as FlagFilter
|
||||
}
|
||||
|
||||
const heapId = sp.get('heap_id')
|
||||
if (heapId) out.heapId = heapId
|
||||
|
||||
const folderId = sp.get('folder_id')
|
||||
if (folderId) out.folderId = folderId
|
||||
|
||||
const tagIds = sp.get('tag_ids')
|
||||
if (tagIds) {
|
||||
const ids = tagIds.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
if (ids.length > 0) out.tagIds = ids
|
||||
}
|
||||
|
||||
if (sp.get('duplicates') === 'true') out.duplicates = true
|
||||
|
||||
const groupBy = sp.get('group')
|
||||
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
|
||||
|
||||
const section = sp.get('section')
|
||||
if (section) out.currentSection = section
|
||||
|
||||
const sortBy = sp.get('sort')
|
||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||
out.sortBy = sortBy as SortField
|
||||
}
|
||||
|
||||
const sortOrder = sp.get('order')
|
||||
if (sortOrder && ALLOWED_SORT_ORDERS.includes(sortOrder as SortOrder)) {
|
||||
out.sortOrder = sortOrder as SortOrder
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function writeUrl(f: FilterState & { currentSection?: string }) {
|
||||
const sp = new URLSearchParams()
|
||||
if (f.q.trim()) sp.set('q', f.q.trim())
|
||||
if (f.dateFrom) sp.set('date_from', f.dateFrom)
|
||||
if (f.dateTo) sp.set('date_to', f.dateTo)
|
||||
if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(','))
|
||||
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||
if (f.folderId) sp.set('folder_id', f.folderId)
|
||||
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
|
||||
if (f.duplicates) sp.set('duplicates', 'true')
|
||||
if (f.groupBy !== 'date') sp.set('group', f.groupBy)
|
||||
if (f.currentSection && f.currentSection !== 'all-photos')
|
||||
sp.set('section', f.currentSection)
|
||||
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
||||
|
||||
const search = sp.toString()
|
||||
const next = search ? `?${search}` : window.location.pathname
|
||||
if (next !== window.location.search && next !== window.location.pathname + window.location.search) {
|
||||
window.history.replaceState(null, '', next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional URL <-> filterStore sync. Hydrates the store from the URL on
|
||||
* mount, then writes any subsequent store changes back to the URL via
|
||||
* history.replaceState (no navigation).
|
||||
*/
|
||||
export function useFilterUrlSync() {
|
||||
const hydrated = useRef(false)
|
||||
|
||||
// Hydrate once on mount.
|
||||
useEffect(() => {
|
||||
const fromUrl = parseUrl()
|
||||
if (Object.keys(fromUrl).length > 0) {
|
||||
useFilterStore.getState().hydrate(fromUrl)
|
||||
}
|
||||
hydrated.current = true
|
||||
}, [])
|
||||
|
||||
// Mirror store -> URL on every change (after hydration).
|
||||
useEffect(() => {
|
||||
return useFilterStore.subscribe((state) => {
|
||||
if (!hydrated.current) return
|
||||
writeUrl(state)
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { sourceFolders, type FolderTreeNode } from '../services/api'
|
||||
|
||||
export const FOLDER_TREE_QUERY_KEY = ['folders', 'tree'] as const
|
||||
|
||||
export function useFolderTreeQuery() {
|
||||
return useQuery<FolderTreeNode[]>({
|
||||
queryKey: FOLDER_TREE_QUERY_KEY,
|
||||
queryFn: sourceFolders.tree,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
/** Walk the tree to find a node by id. Used for chip name lookups. */
|
||||
export function findFolderInTree(
|
||||
tree: FolderTreeNode[] | undefined,
|
||||
id: string
|
||||
): FolderTreeNode | null {
|
||||
if (!tree) return null
|
||||
for (const node of tree) {
|
||||
if (node.id === id) return node
|
||||
const child = findFolderInTree(node.children, id)
|
||||
if (child) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { heaps as heapsApi, type Heap } from '../services/api'
|
||||
|
||||
export const HEAPS_QUERY_KEY = ['heaps'] as const
|
||||
|
||||
export function useHeapsQuery() {
|
||||
return useQuery<Heap[]>({
|
||||
queryKey: HEAPS_QUERY_KEY,
|
||||
queryFn: heapsApi.list,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
||||
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
||||
import { toast } from '../components/ToastContainer'
|
||||
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onToggleLeftSidebar: () => void
|
||||
onToggleRightSidebar: () => void
|
||||
/** Returns the first photo id in the current timeline, or null if empty. */
|
||||
getFirstPhotoId?: () => string | null
|
||||
}
|
||||
|
||||
interface PhotoUpdate {
|
||||
rating?: number
|
||||
is_discarded?: boolean
|
||||
color_label?: string | null
|
||||
}
|
||||
|
||||
// Spec §6.4 number-key color labels.
|
||||
const COLOR_LABELS: Record<string, string> = {
|
||||
'6': 'red',
|
||||
'7': 'orange',
|
||||
'8': 'yellow',
|
||||
'9': 'green',
|
||||
}
|
||||
|
||||
// Default options shared by every shortcut: preventDefault stops the browser
|
||||
// from claiming the event (Firefox quick-find on letter keys, Cmd+F search,
|
||||
// `/` quick-find, Tab focus traversal). enableOnFormTags is left default-off
|
||||
// so typing in inputs doesn't fire culling shortcuts.
|
||||
const HK_OPTS = { preventDefault: true } as const
|
||||
|
||||
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
||||
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||
|
||||
const isPreview = viewMode === 'preview'
|
||||
|
||||
// Photo mutation shared by every culling shortcut. Reads the active photo
|
||||
// id from the store at fire time so the closure stays fresh without forcing
|
||||
// hotkey re-binding on every selection change.
|
||||
const queryClient = useQueryClient()
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: PhotoUpdate }) =>
|
||||
photosApi.update(id, data),
|
||||
onSuccess: (_data, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', vars.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
})
|
||||
|
||||
const invalidatePhotoQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
|
||||
const bulkRatingMutation = useMutation({
|
||||
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
||||
photosApi.bulkSetRating(ids, rating),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const bulkColorMutation = useMutation({
|
||||
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
|
||||
photosApi.bulkSetColor(ids, color),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const bulkDiscardMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const bulkRestoreMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
/** The set of photo ids the next culling action should apply to.
|
||||
* - Multi-selection → all selected photos
|
||||
* - Single selection → that one photo
|
||||
* - No selection but an activePhotoId set (last clicked) → that one
|
||||
* - Otherwise → empty
|
||||
*/
|
||||
const cullTargets = (): string[] => {
|
||||
const state = usePhotoStore.getState()
|
||||
if (state.selectedPhotos.length > 0) return state.selectedPhotos
|
||||
if (state.activePhotoId) return [state.activePhotoId]
|
||||
return []
|
||||
}
|
||||
|
||||
/** Apply a partial PhotoUpdate to the cull targets. Picks the right
|
||||
* bulk endpoint when there are 2+ photos so a single API call covers
|
||||
* the whole selection. */
|
||||
const updateActive = (data: PhotoUpdate) => {
|
||||
const ids = cullTargets()
|
||||
if (ids.length === 0) return
|
||||
|
||||
if (ids.length === 1) {
|
||||
const id = ids[0]
|
||||
updateMutation.mutate(
|
||||
{ id, data },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Only the discard/restore subset of single-photo updates is
|
||||
// undoable today — rating and color round-trip cleanly enough
|
||||
// that the manual fix is faster than maintaining per-photo
|
||||
// previous-value snapshots.
|
||||
if (data.is_discarded === true) {
|
||||
registerUndoable('Discarded 1 photo', async () => {
|
||||
await photosApi.bulkRestore([id])
|
||||
invalidatePhotoQueries()
|
||||
})
|
||||
} else if (data.is_discarded === false) {
|
||||
registerUndoable('Restored 1 photo', async () => {
|
||||
await photosApi.bulkDiscard([id])
|
||||
invalidatePhotoQueries()
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Multi-selection — fan out to the right bulk endpoint per field.
|
||||
if (data.rating !== undefined) {
|
||||
bulkRatingMutation.mutate({ ids, rating: data.rating })
|
||||
}
|
||||
if (data.color_label !== undefined) {
|
||||
bulkColorMutation.mutate({ ids, color: data.color_label })
|
||||
}
|
||||
if (data.is_discarded === true) {
|
||||
bulkDiscardMutation.mutate(ids, {
|
||||
onSuccess: () => {
|
||||
registerUndoable(
|
||||
`Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||
async () => {
|
||||
await photosApi.bulkRestore(ids)
|
||||
invalidatePhotoQueries()
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
} else if (data.is_discarded === false) {
|
||||
bulkRestoreMutation.mutate(ids, {
|
||||
onSuccess: () => {
|
||||
registerUndoable(
|
||||
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||
async () => {
|
||||
await photosApi.bulkDiscard(ids)
|
||||
invalidatePhotoQueries()
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// P key (Pick): toggle the current selection's membership in the active
|
||||
// heap. If every selected photo is already a member, remove them; otherwise
|
||||
// add the missing ones. No active heap → toast hint.
|
||||
const heapMutation = useMutation({
|
||||
mutationFn: ({
|
||||
heapId,
|
||||
photoIds,
|
||||
remove,
|
||||
}: {
|
||||
heapId: string
|
||||
photoIds: string[]
|
||||
remove: boolean
|
||||
}) =>
|
||||
remove
|
||||
? heapsApi.removePhotos(heapId, photoIds)
|
||||
: heapsApi.addPhotos(heapId, photoIds),
|
||||
// Optimistically flip the membership cache so the basket affordance
|
||||
// updates instantly and a quick second P press reads the new state
|
||||
// (otherwise invalidate-then-refetch leaves a brief stale window).
|
||||
onMutate: ({ heapId, photoIds, remove }) => {
|
||||
const key = ['heap-photo-ids', heapId] as const
|
||||
const previous = queryClient.getQueryData<string[]>(key)
|
||||
const set = new Set(previous ?? [])
|
||||
if (remove) photoIds.forEach((id) => set.delete(id))
|
||||
else photoIds.forEach((id) => set.add(id))
|
||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||
return { previous }
|
||||
},
|
||||
onError: (e: any, _vars, ctx) => {
|
||||
// Roll back the optimistic update on failure.
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
|
||||
}
|
||||
toast.error('Heap update failed', e.message || 'Unknown error')
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
||||
(h) => h.id === vars.heapId
|
||||
)
|
||||
const heapName = heap?.name ?? 'heap'
|
||||
if (vars.remove) {
|
||||
const removed = data?.removed ?? 0
|
||||
toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
|
||||
} else {
|
||||
const added = data?.added ?? 0
|
||||
const already = data?.already_present ?? 0
|
||||
if (added > 0) {
|
||||
toast.success(
|
||||
`Added to ${heapName}`,
|
||||
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, vars) => {
|
||||
// Re-sync with server truth (heap counts in particular need this).
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
||||
},
|
||||
})
|
||||
|
||||
const togglePickOnSelection = () => {
|
||||
const state = usePhotoStore.getState()
|
||||
const ids =
|
||||
state.selectedPhotos.length > 0
|
||||
? state.selectedPhotos
|
||||
: state.activePhotoId
|
||||
? [state.activePhotoId]
|
||||
: []
|
||||
if (ids.length === 0) {
|
||||
toast.info('Nothing selected', 'Select photos first, then press P')
|
||||
return
|
||||
}
|
||||
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
||||
const active = heapsList.find((h) => h.is_active)
|
||||
if (!active) {
|
||||
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
|
||||
return
|
||||
}
|
||||
// Determine direction: if every selected photo is already a member, this
|
||||
// press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
|
||||
// Lightroom's flag-toggle works.
|
||||
const memberIds =
|
||||
queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
|
||||
const memberSet = new Set(memberIds)
|
||||
const allMembers = ids.every((id) => memberSet.has(id))
|
||||
heapMutation.mutate({
|
||||
heapId: active.id,
|
||||
photoIds: ids,
|
||||
remove: allMembers,
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle sidebars. The right sidebar `i` shortcut is grid-only — in
|
||||
// preview mode the PreviewView mounts its own `i` handler for the
|
||||
// overlay info panel, and we don't want both to fire.
|
||||
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
||||
useHotkeys('i', onToggleRightSidebar, { ...HK_OPTS, enabled: !isPreview })
|
||||
|
||||
// Cmd/Ctrl+Z → pop the most recent undoable action and reverse it.
|
||||
// Bound at the global level so it works in both grid and preview modes.
|
||||
useHotkeys(
|
||||
'mod+z',
|
||||
async () => {
|
||||
const entry = useUndoStore.getState().pop()
|
||||
if (!entry) {
|
||||
toast.info('Nothing to undo')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await entry.undo()
|
||||
} catch (e: any) {
|
||||
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
||||
toast.error('Undo failed', e?.message || 'Unknown error')
|
||||
}
|
||||
},
|
||||
HK_OPTS
|
||||
)
|
||||
|
||||
// Search focus (/ or Cmd/Ctrl+F).
|
||||
const focusSearch = () => {
|
||||
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
useHotkeys('/', focusSearch, HK_OPTS)
|
||||
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
||||
|
||||
// Space toggles the preview view (open from grid, close from preview).
|
||||
// Double-click on a thumbnail does the same.
|
||||
const openPreviewFromGrid = () => {
|
||||
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
||||
if (id) openPreview(id)
|
||||
}
|
||||
|
||||
const togglePreview = () => {
|
||||
if (isPreview) closePreview()
|
||||
else openPreviewFromGrid()
|
||||
}
|
||||
|
||||
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
||||
|
||||
// ── Culling shortcuts (work in both grid and preview) ────────────────────
|
||||
|
||||
// Star rating: 1-5 set, 0 clears.
|
||||
useHotkeys(
|
||||
'1,2,3,4,5',
|
||||
(_e, handler) => {
|
||||
const rating = parseInt(handler.keys![0])
|
||||
if (Number.isFinite(rating)) updateActive({ rating })
|
||||
},
|
||||
HK_OPTS
|
||||
)
|
||||
|
||||
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||
|
||||
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
||||
// adding it to the heap you set as active. Toggling on already-picked
|
||||
// photos removes them from the heap.
|
||||
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
||||
|
||||
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
|
||||
|
||||
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
|
||||
|
||||
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
||||
useHotkeys(
|
||||
'6,7,8,9',
|
||||
(_e, handler) => {
|
||||
const label = COLOR_LABELS[handler.keys![0]]
|
||||
if (label) updateActive({ color_label: label })
|
||||
},
|
||||
HK_OPTS
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { library, type LibraryStats } from '../services/api'
|
||||
|
||||
export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const
|
||||
|
||||
/**
|
||||
* Per-section counts for the LeftSidebar badges (All Photos, Rated,
|
||||
* Duplicates, Discarded). Cached briefly so navigating around doesn't
|
||||
* re-fetch on every click; invalidated on photo mutations through the
|
||||
* standard ['photos'] invalidation in the mutation onSuccess paths.
|
||||
*/
|
||||
export function useLibraryStatsQuery() {
|
||||
return useQuery<LibraryStats>({
|
||||
queryKey: LIBRARY_STATS_QUERY_KEY,
|
||||
queryFn: library.stats,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
||||
import api from '../services/api'
|
||||
import type { Photo } from '../types/photo'
|
||||
|
||||
/**
|
||||
* Single source of truth for the timeline photos query. Both Timeline and
|
||||
* PreviewView call this so they share one cache entry — previously
|
||||
* PreviewView looked the cache up by key directly, which broke the moment
|
||||
* Timeline's key gained the filter params.
|
||||
*/
|
||||
export function usePhotosQuery() {
|
||||
const q = useFilterStore((s) => s.q)
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const heapId = useFilterStore((s) => s.heapId)
|
||||
const folderId = useFilterStore((s) => s.folderId)
|
||||
const tagIds = useFilterStore((s) => s.tagIds)
|
||||
const duplicates = useFilterStore((s) => s.duplicates)
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||
|
||||
const filterParams = useMemo(
|
||||
() =>
|
||||
filtersToParams({
|
||||
q,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
mediaTypes,
|
||||
ratingMin,
|
||||
colorLabel,
|
||||
flag,
|
||||
heapId,
|
||||
folderId,
|
||||
tagIds,
|
||||
duplicates,
|
||||
groupBy,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
)
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['photos', filterParams],
|
||||
queryFn: async () => {
|
||||
// Goes through the shared axios instance so it inherits the
|
||||
// relative /api/v1 baseURL — same-origin behind the nginx / vite
|
||||
// proxy, no CORS dance required from another machine.
|
||||
const response = await api.get<{ photos: Photo[]; total: number }>(
|
||||
'/photos',
|
||||
{
|
||||
params: {
|
||||
page: 1,
|
||||
per_page: 500,
|
||||
...filterParams,
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.photos || []
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { tags as tagsApi, type Tag } from '../services/api'
|
||||
|
||||
export const TAGS_QUERY_KEY = ['tags'] as const
|
||||
|
||||
export function useTagsQuery() {
|
||||
return useQuery<Tag[]>({
|
||||
queryKey: TAGS_QUERY_KEY,
|
||||
queryFn: tagsApi.list,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg: #111110;
|
||||
--color-surface: #161615;
|
||||
--color-surface-2: #1c1c1a;
|
||||
--color-surface-offset: #222220;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-text: #e8e6e0;
|
||||
--color-text-muted: #878580;
|
||||
--color-text-faint: #4a4845;
|
||||
--color-primary: #4f98a3;
|
||||
--color-pick: #4f9e5c;
|
||||
--color-reject: #c25a5a;
|
||||
--color-star: #d4a340;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg text-text;
|
||||
font-family: 'Geist', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-surface;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-surface-offset rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-text-faint;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,393 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// Relative API base. In production the nginx in front of the SPA proxies
|
||||
// /api/ to the backend container; in dev the vite server has the same
|
||||
// proxy in vite.config.ts. Using a relative URL means requests are
|
||||
// always same-origin, so the app works whether you hit it from
|
||||
// localhost, a LAN IP, or a reverse proxy without any CORS dance.
|
||||
const API_BASE_URL = '/api/v1'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
||||
// .env → bootstrap on backend startup), so the UI only reads them and
|
||||
// optionally renames the display label.
|
||||
export interface FolderTreeNode {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
photo_count: number
|
||||
children: FolderTreeNode[]
|
||||
}
|
||||
|
||||
export const sourceFolders = {
|
||||
list: async () => {
|
||||
const response = await api.get('/folders')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Recursive folder tree, one root per active source root. */
|
||||
tree: async (): Promise<FolderTreeNode[]> => {
|
||||
const response = await api.get('/folders/tree')
|
||||
return response.data
|
||||
},
|
||||
|
||||
scan: async (folderId: string) => {
|
||||
const response = await api.post(`/folders/${folderId}/scan`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Rename a folder. SourceRoot ids only update the display label;
|
||||
* Folder ids actually move the directory on disk and update every
|
||||
* descendant photo's filepath. */
|
||||
rename: async (folderId: string, name: string) => {
|
||||
const response = await api.patch(`/folders/${folderId}`, { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Create a new sub-folder under an existing Folder. parent_id MUST
|
||||
* be a Folder row id (not a SourceRoot id). */
|
||||
create: async (parentId: string, name: string) => {
|
||||
const response = await api.post('/folders', {
|
||||
name,
|
||||
parent_id: parentId,
|
||||
})
|
||||
return response.data as { id: string; name: string; path: string; parent_id: string }
|
||||
},
|
||||
|
||||
/** Delete a folder. mode=discard moves all photos under it to the
|
||||
* discard pile (recoverable) and leaves the folder + on-disk dir
|
||||
* alone. mode=permanent unlinks files, removes folder rows, and
|
||||
* rmtrees the directory — irreversible. */
|
||||
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
|
||||
const response = await api.delete(`/folders/${folderId}`, {
|
||||
params: { mode },
|
||||
})
|
||||
return response.data as {
|
||||
status: string
|
||||
mode: string
|
||||
discarded?: number
|
||||
deleted_photos?: number
|
||||
file_errors?: number
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Photos API
|
||||
export const photos = {
|
||||
list: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
folder_id?: string
|
||||
heap_id?: string
|
||||
rating?: number
|
||||
flag?: string
|
||||
}) => {
|
||||
const response = await api.get('/photos', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
get: async (photoId: string) => {
|
||||
const response = await api.get(`/photos/${photoId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (photoId: string, data: {
|
||||
filename?: string
|
||||
rating?: number
|
||||
user_title?: string | null
|
||||
user_notes?: string | null
|
||||
color_label?: string | null
|
||||
is_picked?: boolean
|
||||
is_discarded?: boolean
|
||||
taken_at?: string
|
||||
}) => {
|
||||
const response = await api.patch(`/photos/${photoId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Bulk discard — matches the backend BulkAction schema. */
|
||||
bulkDiscard: async (photoIds: string[]) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'discard',
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Bulk restore from discarded. */
|
||||
bulkRestore: async (photoIds: string[]) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'restore',
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Bulk set rating (0-5). */
|
||||
bulkSetRating: async (photoIds: string[], rating: number) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'set_rating',
|
||||
value: rating,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Bulk set color label (or null to clear). */
|
||||
bulkSetColor: async (photoIds: string[], color: string | null) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'set_color',
|
||||
value: color,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Add the listed tags to every listed photo. Idempotent — re-adding
|
||||
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
|
||||
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'add_tags',
|
||||
value: tagIds,
|
||||
})
|
||||
return response.data as { status: string; added: number }
|
||||
},
|
||||
|
||||
/** Remove the listed tags from every listed photo. Removing a
|
||||
* non-member is a no-op. Returns { removed: N }. */
|
||||
bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'remove_tags',
|
||||
value: tagIds,
|
||||
})
|
||||
return response.data as { status: string; removed: number }
|
||||
},
|
||||
|
||||
/** Move photos into a target folder (or source root). Returns
|
||||
* { moved, errors[] }. */
|
||||
move: async (photoIds: string[], targetId: string) => {
|
||||
const response = await api.post('/photos/move', {
|
||||
photo_ids: photoIds,
|
||||
target_id: targetId,
|
||||
})
|
||||
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
|
||||
},
|
||||
|
||||
/** Copy photos into a target folder. Originals are unaffected; new
|
||||
* rows are created with is_duplicate=true. */
|
||||
copy: async (photoIds: string[], targetId: string) => {
|
||||
const response = await api.post('/photos/copy', {
|
||||
photo_ids: photoIds,
|
||||
target_id: targetId,
|
||||
})
|
||||
return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> }
|
||||
},
|
||||
|
||||
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
|
||||
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
||||
},
|
||||
|
||||
getOriginalUrl: (photoId: string) => {
|
||||
return `${API_BASE_URL}/photos/${photoId}/original`
|
||||
},
|
||||
|
||||
/** Full-resolution display URL. Backend serves the original for web-safe
|
||||
* formats and a transcoded WebP for RAW/HEIC/TIFF. */
|
||||
getProxyUrl: (photoId: string) => {
|
||||
return `${API_BASE_URL}/photos/${photoId}/proxy`
|
||||
},
|
||||
}
|
||||
|
||||
// Library API
|
||||
export const library = {
|
||||
scan: async () => {
|
||||
const response = await api.post('/library/scan')
|
||||
return response.data
|
||||
},
|
||||
|
||||
scanStatus: async () => {
|
||||
const response = await api.get('/library/scan/status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
stats: async (): Promise<LibraryStats> => {
|
||||
const response = await api.get('/library/stats')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
all_photos: number
|
||||
rated: number
|
||||
duplicates: number
|
||||
discarded: number
|
||||
total_photos: number
|
||||
total_videos: number
|
||||
total_size: number
|
||||
total_size_gb: number
|
||||
}
|
||||
|
||||
// Heaps API
|
||||
export interface Heap {
|
||||
id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
photo_count: number
|
||||
}
|
||||
|
||||
export const heaps = {
|
||||
list: async (): Promise<Heap[]> => {
|
||||
const response = await api.get('/heaps')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (name: string): Promise<Heap> => {
|
||||
const response = await api.post('/heaps', { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (
|
||||
heapId: string,
|
||||
data: { name?: string; is_active?: boolean }
|
||||
): Promise<Heap> => {
|
||||
const response = await api.patch(`/heaps/${heapId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (heapId: string): Promise<void> => {
|
||||
await api.delete(`/heaps/${heapId}`)
|
||||
},
|
||||
|
||||
/** Duplicate a heap, copying its membership but never marking the new
|
||||
* one as active. The new heap is named "{name} (copy)". */
|
||||
duplicate: async (heapId: string): Promise<Heap> => {
|
||||
const response = await api.post(`/heaps/${heapId}/duplicate`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Lightweight: just the photo ids in a heap, for client-side membership
|
||||
* lookups (the basket affordance on thumbnails). */
|
||||
photoIds: async (heapId: string): Promise<string[]> => {
|
||||
const response = await api.get(`/heaps/${heapId}/photo_ids`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
addPhotos: async (heapId: string, photoIds: string[]) => {
|
||||
const response = await api.post(`/heaps/${heapId}/photos`, {
|
||||
photo_ids: photoIds,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
removePhotos: async (heapId: string, photoIds: string[]) => {
|
||||
const response = await api.delete(`/heaps/${heapId}/photos`, {
|
||||
data: { photo_ids: photoIds },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Convert a heap into a folder by moving (or copying) every member
|
||||
* photo into the target directory. Optionally creates a subfolder
|
||||
* inside the target by name. */
|
||||
convert: async (
|
||||
heapId: string,
|
||||
body: {
|
||||
target_id: string
|
||||
mode: 'move' | 'copy'
|
||||
delete_heap: boolean
|
||||
subfolder_name?: string | null
|
||||
}
|
||||
) => {
|
||||
const response = await api.post(`/heaps/${heapId}/convert`, body)
|
||||
return response.data as {
|
||||
status: string
|
||||
mode: 'move' | 'copy'
|
||||
moved: number
|
||||
copied: number
|
||||
errors: Array<{ id: string; error: string }>
|
||||
heap_deleted: boolean
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Tags API
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
photo_count: number
|
||||
}
|
||||
|
||||
export const tags = {
|
||||
list: async (): Promise<Tag[]> => {
|
||||
const response = await api.get('/tags')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (name: string, color?: string): Promise<Tag> => {
|
||||
const response = await api.post('/tags', { name, color })
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
|
||||
const response = await api.patch(`/tags/${tagId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (tagId: string): Promise<void> => {
|
||||
await api.delete(`/tags/${tagId}`)
|
||||
},
|
||||
|
||||
/** Add one or more tags to a photo. */
|
||||
addToPhoto: async (photoId: string, tagIds: string[]) => {
|
||||
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Remove a tag from a photo. */
|
||||
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
|
||||
await api.delete(`/photos/${photoId}/tags/${tagId}`)
|
||||
},
|
||||
}
|
||||
|
||||
// Discard API
|
||||
export const discard = {
|
||||
list: async () => {
|
||||
const response = await api.get('/discard')
|
||||
return response.data
|
||||
},
|
||||
|
||||
restore: async (photoIds: string[]) => {
|
||||
const response = await api.post('/discard/restore', {
|
||||
photo_ids: photoIds,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
empty: async () => {
|
||||
const response = await api.delete('/discard/empty')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Permanently delete a specific subset of discarded photos. The backend
|
||||
* silently skips ids that aren't in the pile, so this can never bypass
|
||||
* the soft-delete safety net. */
|
||||
deletePermanent: async (photoIds: string[]) => {
|
||||
const response = await api.delete('/discard', {
|
||||
data: { photo_ids: photoIds },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -1,234 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ColorLabel } from '../constants/colorLabels'
|
||||
|
||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
||||
export type { ColorLabel }
|
||||
export type FlagFilter = 'any' | 'discarded'
|
||||
export type SortField =
|
||||
| 'taken_at'
|
||||
| 'added_at'
|
||||
| 'filename'
|
||||
| 'file_size'
|
||||
| 'rating'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
export type GroupBy = 'date' | 'tag'
|
||||
|
||||
export interface FilterState {
|
||||
q: string
|
||||
dateFrom: string | null // ISO yyyy-mm-dd
|
||||
dateTo: string | null
|
||||
mediaTypes: MediaType[]
|
||||
ratingMin: number // 0-5; 0 means no filter
|
||||
colorLabel: ColorLabel | null
|
||||
flag: FlagFilter
|
||||
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
||||
* on the heap store — that's the target for the T shortcut. */
|
||||
heapId: string | null
|
||||
/** When set, restrict to photos in this folder. */
|
||||
folderId: string | null
|
||||
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
|
||||
tagIds: string[]
|
||||
/** When true, restrict to photos flagged as duplicates by the scanner. */
|
||||
duplicates: boolean
|
||||
/** Visual grouping mode. 'date' groups by month when sortBy is a date
|
||||
* field; 'tag' groups by photo tag membership. Independent of filters. */
|
||||
groupBy: GroupBy
|
||||
sortBy: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
|
||||
/** Identifies which "section" of the app the user is currently viewing.
|
||||
* Sections each carry their own filter state — switching to one restores
|
||||
* whatever filters were active there last time, switching away saves the
|
||||
* current state under the section being left. */
|
||||
export const ALL_PHOTOS_SECTION = 'all-photos'
|
||||
|
||||
interface FilterStore extends FilterState {
|
||||
/** The active section id. Changes via navigateToSection. */
|
||||
currentSection: string
|
||||
/** Per-section snapshot of filter state, in-memory. Restored on return. */
|
||||
sectionFilters: Record<string, FilterState>
|
||||
/** Per-section "intrinsic" filters — the preset that defines what makes
|
||||
* a section that section (e.g. flag=discarded for the discarded
|
||||
* section). Used by clearAll to reset within a section without
|
||||
* navigating away. */
|
||||
sectionPresets: Record<string, Partial<FilterState>>
|
||||
|
||||
setQ: (q: string) => void
|
||||
setDateFrom: (date: string | null) => void
|
||||
setDateTo: (date: string | null) => void
|
||||
toggleMediaType: (t: MediaType) => void
|
||||
setRatingMin: (rating: number) => void
|
||||
setColorLabel: (label: ColorLabel | null) => void
|
||||
setFlag: (flag: FlagFilter) => void
|
||||
setHeapId: (id: string | null) => void
|
||||
setFolderId: (id: string | null) => void
|
||||
setTagIds: (ids: string[]) => void
|
||||
toggleTagId: (id: string) => void
|
||||
setDuplicates: (v: boolean) => void
|
||||
setGroupBy: (mode: GroupBy) => void
|
||||
setSortBy: (field: SortField) => void
|
||||
setSortOrder: (order: SortOrder) => void
|
||||
toggleSortOrder: () => void
|
||||
|
||||
/** Navigate to a section. Saves the current section's filter state into
|
||||
* the in-memory map under the OLD section id, then loads the saved
|
||||
* state for the destination — or, if none exists, applies the preset
|
||||
* overrides on top of INITIAL_FILTERS. The preset is also stored so
|
||||
* clearAll inside the section resets correctly. */
|
||||
navigateToSection: (
|
||||
sectionId: string,
|
||||
presetOverrides?: Partial<FilterState>
|
||||
) => void
|
||||
|
||||
hydrate: (partial: Partial<FilterState> & { currentSection?: string }) => void
|
||||
/** Reset filters within the CURRENT section back to its preset. Doesn't
|
||||
* navigate. For an explicit "go to all photos" use navigateToSection. */
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
export const INITIAL_FILTERS: FilterState = {
|
||||
q: '',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
mediaTypes: [],
|
||||
ratingMin: 0,
|
||||
colorLabel: null,
|
||||
flag: 'any',
|
||||
heapId: null,
|
||||
folderId: null,
|
||||
tagIds: [],
|
||||
duplicates: false,
|
||||
groupBy: 'date',
|
||||
sortBy: 'taken_at',
|
||||
sortOrder: 'desc',
|
||||
}
|
||||
|
||||
/** Pull the FilterState slice out of the full store, dropping the
|
||||
* control fields. Used when snapshotting current filters into the
|
||||
* per-section map. */
|
||||
function snapshotFilters(s: FilterState): FilterState {
|
||||
return {
|
||||
q: s.q,
|
||||
dateFrom: s.dateFrom,
|
||||
dateTo: s.dateTo,
|
||||
mediaTypes: [...s.mediaTypes],
|
||||
ratingMin: s.ratingMin,
|
||||
colorLabel: s.colorLabel,
|
||||
flag: s.flag,
|
||||
heapId: s.heapId,
|
||||
folderId: s.folderId,
|
||||
tagIds: [...s.tagIds],
|
||||
duplicates: s.duplicates,
|
||||
groupBy: s.groupBy,
|
||||
sortBy: s.sortBy,
|
||||
sortOrder: s.sortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
export const useFilterStore = create<FilterStore>((set) => ({
|
||||
...INITIAL_FILTERS,
|
||||
currentSection: ALL_PHOTOS_SECTION,
|
||||
sectionFilters: {},
|
||||
sectionPresets: { [ALL_PHOTOS_SECTION]: {} },
|
||||
|
||||
setQ: (q) => set({ q }),
|
||||
setDateFrom: (dateFrom) => set({ dateFrom }),
|
||||
setDateTo: (dateTo) => set({ dateTo }),
|
||||
toggleMediaType: (t) =>
|
||||
set((s) => ({
|
||||
mediaTypes: s.mediaTypes.includes(t)
|
||||
? s.mediaTypes.filter((x) => x !== t)
|
||||
: [...s.mediaTypes, t],
|
||||
})),
|
||||
setRatingMin: (ratingMin) => set({ ratingMin }),
|
||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||
setFlag: (flag) => set({ flag }),
|
||||
setHeapId: (heapId) => set({ heapId }),
|
||||
setFolderId: (folderId) => set({ folderId }),
|
||||
setTagIds: (tagIds) => set({ tagIds }),
|
||||
toggleTagId: (id) =>
|
||||
set((s) => ({
|
||||
tagIds: s.tagIds.includes(id)
|
||||
? s.tagIds.filter((t) => t !== id)
|
||||
: [...s.tagIds, id],
|
||||
})),
|
||||
setDuplicates: (duplicates) => set({ duplicates }),
|
||||
setGroupBy: (groupBy) => set({ groupBy }),
|
||||
setSortBy: (sortBy) => set({ sortBy }),
|
||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||
toggleSortOrder: () =>
|
||||
set((s) => ({ sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' })),
|
||||
|
||||
navigateToSection: (sectionId, presetOverrides = {}) =>
|
||||
set((s) => {
|
||||
// Snapshot the current section's filters before switching.
|
||||
const updatedSectionFilters = {
|
||||
...s.sectionFilters,
|
||||
[s.currentSection]: snapshotFilters(s),
|
||||
}
|
||||
// Remember this section's intrinsic preset (last write wins, which
|
||||
// is fine — sections are uniquely identified by id).
|
||||
const updatedSectionPresets = {
|
||||
...s.sectionPresets,
|
||||
[sectionId]: presetOverrides,
|
||||
}
|
||||
// Restore the destination section's saved state, or apply the
|
||||
// preset on top of fresh defaults if it's never been visited.
|
||||
const saved = updatedSectionFilters[sectionId]
|
||||
const next: FilterState = saved
|
||||
? saved
|
||||
: { ...INITIAL_FILTERS, ...presetOverrides }
|
||||
|
||||
return {
|
||||
...next,
|
||||
currentSection: sectionId,
|
||||
sectionFilters: updatedSectionFilters,
|
||||
sectionPresets: updatedSectionPresets,
|
||||
}
|
||||
}),
|
||||
|
||||
hydrate: (partial) => set(partial),
|
||||
clearAll: () =>
|
||||
set((s) => {
|
||||
const preset = s.sectionPresets[s.currentSection] ?? {}
|
||||
return { ...INITIAL_FILTERS, ...preset }
|
||||
}),
|
||||
}))
|
||||
|
||||
/** Convert filter state to the query params the backend list endpoint expects.
|
||||
* Empty / default values are omitted so the cache key is stable. */
|
||||
export function filtersToParams(f: FilterState): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (f.q.trim()) params.q = f.q.trim()
|
||||
if (f.dateFrom) params.date_from = f.dateFrom
|
||||
if (f.dateTo) params.date_to = f.dateTo
|
||||
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
|
||||
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
||||
if (f.colorLabel) params.color_label = f.colorLabel
|
||||
if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||
if (f.heapId) params.heap_id = f.heapId
|
||||
if (f.folderId) params.folder_id = f.folderId
|
||||
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
|
||||
if (f.duplicates) params.is_duplicate = 'true'
|
||||
params.sort = f.sortBy
|
||||
params.order = f.sortOrder
|
||||
return params
|
||||
}
|
||||
|
||||
/** True if any filter (other than the search box) is active. */
|
||||
export function hasActiveFilters(f: FilterState): boolean {
|
||||
return (
|
||||
f.q.trim() !== '' ||
|
||||
f.dateFrom !== null ||
|
||||
f.dateTo !== null ||
|
||||
f.mediaTypes.length > 0 ||
|
||||
f.ratingMin > 0 ||
|
||||
f.colorLabel !== null ||
|
||||
f.flag !== 'any' ||
|
||||
f.heapId !== null ||
|
||||
f.folderId !== null ||
|
||||
f.tagIds.length > 0 ||
|
||||
f.duplicates
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Photo } from '../types/photo'
|
||||
|
||||
type ViewMode = 'grid' | 'preview'
|
||||
|
||||
interface PhotoStore {
|
||||
photos: Photo[]
|
||||
selectedPhotos: string[]
|
||||
activePhotoId: string | null
|
||||
lastSelectedIndex: number | null
|
||||
rangeStartIndex: number | null
|
||||
viewMode: ViewMode
|
||||
/** Flat sequence of photo ids in the order they currently appear in
|
||||
* the timeline grid (including duplicates from tag-grouping). The
|
||||
* preview view walks this sequence so arrow nav matches the order
|
||||
* the user actually sees. Owned by the Timeline component, which
|
||||
* rewrites it whenever its layout items change. */
|
||||
visiblePhotoIds: string[]
|
||||
|
||||
setPhotos: (photos: Photo[]) => void
|
||||
selectPhoto: (id: string, index: number) => void
|
||||
togglePhotoSelection: (id: string, index: number) => void
|
||||
selectRange: (endIndex: number) => void
|
||||
deselectPhoto: (id: string) => void
|
||||
clearSelection: () => void
|
||||
setActivePhoto: (id: string | null) => void
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setVisiblePhotoIds: (ids: string[]) => void
|
||||
openPreview: (id: string) => void
|
||||
closePreview: () => void
|
||||
}
|
||||
|
||||
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
photos: [],
|
||||
selectedPhotos: [],
|
||||
activePhotoId: null,
|
||||
lastSelectedIndex: null,
|
||||
rangeStartIndex: null,
|
||||
viewMode: 'grid',
|
||||
visiblePhotoIds: [],
|
||||
|
||||
setPhotos: (photos) => set({ photos }),
|
||||
|
||||
selectPhoto: (id, index) => set({
|
||||
selectedPhotos: [id],
|
||||
activePhotoId: id,
|
||||
lastSelectedIndex: index,
|
||||
rangeStartIndex: index,
|
||||
}),
|
||||
|
||||
togglePhotoSelection: (id, index) => set((state) => {
|
||||
const isSelected = state.selectedPhotos.includes(id)
|
||||
return {
|
||||
selectedPhotos: isSelected
|
||||
? state.selectedPhotos.filter(photoId => photoId !== id)
|
||||
: [...state.selectedPhotos, id],
|
||||
lastSelectedIndex: index,
|
||||
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
|
||||
}
|
||||
}),
|
||||
|
||||
selectRange: (endIndex) => {
|
||||
// Note: The actual range selection logic should be handled in the Timeline component
|
||||
// which has access to the photos array
|
||||
set({
|
||||
lastSelectedIndex: endIndex,
|
||||
})
|
||||
},
|
||||
|
||||
deselectPhoto: (id) => set((state) => ({
|
||||
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
|
||||
})),
|
||||
|
||||
clearSelection: () => set({
|
||||
selectedPhotos: [],
|
||||
lastSelectedIndex: null,
|
||||
rangeStartIndex: null,
|
||||
}),
|
||||
|
||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
|
||||
// No-op when the content is identical so callers can fire from an
|
||||
// effect without risking a re-render loop.
|
||||
setVisiblePhotoIds: (visiblePhotoIds) =>
|
||||
set((s) => {
|
||||
const prev = s.visiblePhotoIds
|
||||
if (prev.length === visiblePhotoIds.length) {
|
||||
let same = true
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== visiblePhotoIds[i]) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (same) return s
|
||||
}
|
||||
return { visiblePhotoIds }
|
||||
}),
|
||||
|
||||
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
|
||||
|
||||
closePreview: () => set({ viewMode: 'grid' }),
|
||||
}))
|
||||
@@ -1,71 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { toast } from '../components/ToastContainer'
|
||||
|
||||
const MAX_STACK = 20
|
||||
|
||||
export interface UndoEntry {
|
||||
id: string
|
||||
/** Short description of what happened, e.g. "Discarded 12 photos". */
|
||||
label: string
|
||||
/** Function that reverses the action. May be async; errors should be
|
||||
* surfaced via toast.error from inside the function. */
|
||||
undo: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface UndoStore {
|
||||
stack: UndoEntry[]
|
||||
/** Push a new entry. Caps the stack at MAX_STACK by dropping the oldest. */
|
||||
push: (entry: Omit<UndoEntry, 'id'>) => void
|
||||
/** Pop the most recent entry. Returns null when the stack is empty. */
|
||||
pop: () => UndoEntry | null
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export const useUndoStore = create<UndoStore>((set, get) => ({
|
||||
stack: [],
|
||||
|
||||
push: (entry) => {
|
||||
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6)
|
||||
set((s) => {
|
||||
const next = [...s.stack, { ...entry, id }]
|
||||
if (next.length > MAX_STACK) next.shift()
|
||||
return { stack: next }
|
||||
})
|
||||
},
|
||||
|
||||
pop: () => {
|
||||
const stack = get().stack
|
||||
if (stack.length === 0) return null
|
||||
const last = stack[stack.length - 1]
|
||||
set({ stack: stack.slice(0, -1) })
|
||||
return last
|
||||
},
|
||||
|
||||
clear: () => set({ stack: [] }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Convenience: register an undoable action AND show the user a success
|
||||
* toast with an inline Undo button. The toast and Cmd+Z hotkey both pop
|
||||
* from the same stack so either path works.
|
||||
*/
|
||||
export function registerUndoable(label: string, undo: () => void | Promise<void>) {
|
||||
useUndoStore.getState().push({ label, undo })
|
||||
toast.success(label, 'Press ⌘Z to undo', {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
// Pop the entry we just pushed (or whatever is now on top, if the
|
||||
// user fired multiple actions in quick succession — Undo always
|
||||
// reverses the most recent thing).
|
||||
const entry = useUndoStore.getState().pop()
|
||||
if (!entry) return
|
||||
try {
|
||||
await entry.undo()
|
||||
} catch (e) {
|
||||
// Re-push so the user can try again, and surface the failure.
|
||||
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
||||
toast.error('Undo failed', e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
29
frontend/src/types/images.d.ts
vendored
29
frontend/src/types/images.d.ts
vendored
@@ -1,29 +0,0 @@
|
||||
declare module '*.png' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.webp' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface PhotoTagSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
id: string
|
||||
filepath: string
|
||||
filename: string
|
||||
media_type: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
is_discarded: boolean
|
||||
is_duplicate: boolean
|
||||
file_hash: string
|
||||
folder_id: string | null
|
||||
added_at: string | null
|
||||
thumb_small?: string
|
||||
thumb_medium?: string
|
||||
thumb_large?: string
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Dark-first color scheme for photo apps
|
||||
bg: '#111110',
|
||||
surface: '#161615',
|
||||
'surface-2': '#1c1c1a',
|
||||
'surface-offset': '#222220',
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
text: '#e8e6e0',
|
||||
'text-muted': '#878580',
|
||||
'text-faint': '#4a4845',
|
||||
primary: '#4f98a3', // desaturated teal
|
||||
pick: '#4f9e5c', // green for picked
|
||||
reject: '#c25a5a', // red for rejected
|
||||
star: '#d4a340', // amber for stars
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Geist', 'system-ui', 'sans-serif'],
|
||||
mono: ['Geist Mono', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'shimmer': 'shimmer 2s infinite linear',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { backgroundPosition: '-200% 0' },
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
25
mariadb/init/01-sidecar.sql
Normal file
25
mariadb/init/01-sidecar.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Bootstraps the `mule_sidecar` database + user used by the Go sidecar
|
||||
-- service (per-user heap sharing + folder mutations, stood up in M4).
|
||||
--
|
||||
-- MariaDB runs every .sql in /docker-entrypoint-initdb.d ONCE, on first
|
||||
-- boot of a fresh data volume. Subsequent boots are no-ops.
|
||||
--
|
||||
-- The sidecar's MariaDB user is intentionally scoped to `mule_sidecar.*`
|
||||
-- only — it never has access to PhotoPrism's schema.
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS mule_sidecar
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- The password here is substituted at compose build time via envsubst,
|
||||
-- but MariaDB's init script doesn't expand vars in .sql files. So we use
|
||||
-- a literal placeholder that the user replaces locally — or, simpler,
|
||||
-- we let the M4 sidecar bring-up script create the user via SQL with the
|
||||
-- env-var password. Keeping a placeholder here makes the schema visible
|
||||
-- in source control without leaking creds.
|
||||
--
|
||||
-- TODO (M4): replace this block with an entrypoint that templates the
|
||||
-- password from $SIDECAR_DB_PASSWORD before MariaDB reads the file.
|
||||
CREATE USER IF NOT EXISTS 'sidecar'@'%' IDENTIFIED BY 'replace-at-m4-bringup';
|
||||
GRANT ALL PRIVILEGES ON mule_sidecar.* TO 'sidecar'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
24
mulita.yml
24
mulita.yml
@@ -1,24 +0,0 @@
|
||||
# Mulita configuration file
|
||||
#
|
||||
# Source roots and discard handling are owned by the database — manage them
|
||||
# from the UI (left sidebar → "+ Add Source Folder") or via the API. Only
|
||||
# operational tuning lives here.
|
||||
|
||||
thumbnails:
|
||||
small: 240 # px, longest edge
|
||||
medium: 640
|
||||
large: 1280
|
||||
quality: 85 # JPEG/WebP quality
|
||||
format: webp # output format for thumbs
|
||||
|
||||
scanner:
|
||||
watch: true # use watchfiles inotify
|
||||
initial_scan_on_start: true
|
||||
batch_size: 100
|
||||
concurrent_workers: 4
|
||||
|
||||
performance:
|
||||
max_concurrent_thumbnails: 10
|
||||
cache_ttl: 3600
|
||||
db_pool_size: 20
|
||||
db_pool_recycle: 3600
|
||||
@@ -1,753 +0,0 @@
|
||||
# PhotoVault — Full Application Spec Prompt
|
||||
|
||||
> A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow.
|
||||
> Use this document as the complete specification to build the app from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
Build **PhotoVault**, a self-hosted photo & video management web application optimized for a single-user homelab deployment. The user mounts one or more host folders containing photos/videos; the app indexes them, generates thumbnails, and provides a fast keyboard-driven interface to browse, organize, tag, and manage the library. The architecture must be forward-compatible with AI photo recognition features (face detection, scene classification, CLIP embeddings) to be added in a later phase.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack & Deployment
|
||||
|
||||
### 2.1 Docker Compose (single `docker-compose.yml`)
|
||||
|
||||
```
|
||||
services:
|
||||
frontend — React SPA (Nginx)
|
||||
backend — Python FastAPI
|
||||
db — SQLite (file-based, volume-mounted)
|
||||
worker — Celery + Redis for background thumbnail/indexing tasks
|
||||
redis — Redis (Celery broker)
|
||||
```
|
||||
|
||||
All services declared in one `docker-compose.yml`. Use named volumes for:
|
||||
- `/data/thumbs` — generated thumbnails (persistent)
|
||||
- `/data/db` — SQLite database file
|
||||
- `/data/trash` — files moved to trash
|
||||
|
||||
Photo source folders are mounted as **read-write** bind mounts via an environment variable:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ${PHOTO_DIRS}:/photos:rw
|
||||
```
|
||||
|
||||
`PHOTO_DIRS` supports multiple paths via a config file (`photovault.yml`) described in §4.
|
||||
|
||||
### 2.2 Frontend
|
||||
|
||||
- **React 18** + **Vite**
|
||||
- **Tailwind CSS v4**
|
||||
- **shadcn/ui** component library
|
||||
- **TanStack Query** (React Query) for data fetching & cache
|
||||
- **TanStack Virtual** for virtualized scrolling (critical for performance with thousands of photos)
|
||||
- **Zustand** for global UI state (selection, active photo, heap, filters)
|
||||
- **Framer Motion** for transitions
|
||||
|
||||
### 2.3 Backend
|
||||
|
||||
- **Python 3.12 + FastAPI**
|
||||
- **SQLite** via **SQLAlchemy 2.0** (async) + **Alembic** for migrations
|
||||
- **Celery + Redis** for background tasks (thumbnail generation, folder scanning, metadata extraction)
|
||||
- **pyvips** (libvips) for fast thumbnail generation — preferred over Pillow for speed at scale
|
||||
- **rawpy** for RAW format decoding (CR2, CR3, NEF, ARW, RAF, DNG, ORF, RW2, etc.)
|
||||
- **pillow-heif** for HEIC/HEIF (iPhone photos)
|
||||
- **ffmpeg** (via `ffmpeg-python`) for video thumbnail extraction and metadata
|
||||
- **pyexiftool** (wraps ExifTool binary) for deep metadata extraction from all formats
|
||||
- **Watchfiles** for inotify-based folder watching (auto-detect new/deleted files)
|
||||
|
||||
> **AI-readiness note**: The backend worker architecture is designed to add a `clip_embed` task later (using `open-clip-torch`) that stores 512-dim CLIP embeddings per photo in the DB. Reserve a `embeddings` table with a `photo_id` FK and a `BLOB` column for the vector. No AI code yet — just the schema placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model (SQLite via SQLAlchemy)
|
||||
|
||||
```sql
|
||||
-- Core tables
|
||||
|
||||
photos (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
filepath TEXT UNIQUE NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
folder_id TEXT REFERENCES folders(id),
|
||||
media_type TEXT NOT NULL, -- 'photo' | 'video' | 'raw' | 'heic'
|
||||
original_format TEXT, -- 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size INTEGER,
|
||||
taken_at DATETIME, -- from EXIF DateTimeOriginal, fallback to file mtime
|
||||
taken_at_source TEXT, -- 'exif' | 'filesystem' | 'manual'
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME,
|
||||
is_trashed BOOLEAN DEFAULT 0,
|
||||
trashed_at DATETIME,
|
||||
thumb_small TEXT, -- path to 240px thumb
|
||||
thumb_medium TEXT, -- path to 640px thumb
|
||||
thumb_large TEXT, -- path to 1280px thumb
|
||||
exif_json TEXT, -- full EXIF/XMP blob as JSON
|
||||
user_title TEXT, -- user-edited title
|
||||
user_notes TEXT,
|
||||
rating INTEGER DEFAULT 0, -- 0-5 stars
|
||||
color_label TEXT, -- 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||||
is_picked BOOLEAN DEFAULT 0,
|
||||
is_rejected BOOLEAN DEFAULT 0
|
||||
)
|
||||
|
||||
folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id),
|
||||
source_root_id TEXT REFERENCES source_roots(id),
|
||||
photo_count INTEGER DEFAULT 0,
|
||||
last_scanned DATETIME
|
||||
)
|
||||
|
||||
source_roots (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
|
||||
tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
color TEXT
|
||||
)
|
||||
|
||||
photo_tags (
|
||||
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
|
||||
tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (photo_id, tag_id)
|
||||
)
|
||||
|
||||
heaps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME
|
||||
)
|
||||
|
||||
heap_photos (
|
||||
heap_id TEXT REFERENCES heaps(id) ON DELETE CASCADE,
|
||||
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (heap_id, photo_id)
|
||||
)
|
||||
|
||||
-- AI-readiness placeholder (no implementation yet)
|
||||
embeddings (
|
||||
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
|
||||
model TEXT, -- e.g. 'clip-vit-b32'
|
||||
vector BLOB -- raw float32 bytes
|
||||
)
|
||||
```
|
||||
|
||||
**Indexes**: Create indexes on `photos.taken_at`, `photos.folder_id`, `photos.is_trashed`, `photos.rating`, `photos.color_label`, `photo_tags.tag_id`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
App is configured via a `photovault.yml` file mounted into the backend container:
|
||||
|
||||
```yaml
|
||||
source_roots:
|
||||
- name: "Main Library"
|
||||
path: /photos/main
|
||||
- name: "iPhone Imports"
|
||||
path: /photos/iphone
|
||||
|
||||
thumbnails:
|
||||
small: 240 # px, longest edge
|
||||
medium: 640
|
||||
large: 1280
|
||||
quality: 85 # JPEG quality
|
||||
format: webp # output format for thumbs
|
||||
|
||||
scanner:
|
||||
watch: true # use watchfiles inotify
|
||||
initial_scan_on_start: true
|
||||
|
||||
trash:
|
||||
path: /data/trash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend API (FastAPI)
|
||||
|
||||
All routes under `/api/v1/`. Authentication: none (single-user, homelab). Use async SQLAlchemy sessions.
|
||||
|
||||
### 5.1 Photos
|
||||
|
||||
```
|
||||
GET /photos List photos (pagination + filters — see §5.5)
|
||||
GET /photos/{id} Get single photo with full EXIF + tags
|
||||
GET /photos/{id}/thumb/{size} Serve thumbnail (small|medium|large) — use X-Accel-Redirect for Nginx
|
||||
GET /photos/{id}/original Serve original file (download)
|
||||
PATCH /photos/{id} Update: user_title, user_notes, rating, color_label, is_picked, is_rejected, taken_at (manual override)
|
||||
DELETE /photos/{id} Move to trash (sets is_trashed=1, moves file to /data/trash)
|
||||
POST /photos/bulk Bulk actions: { ids: [], action: 'trash'|'restore'|'delete_permanent'|'move'|'copy'|'add_tag'|'remove_tag'|'set_rating'|'set_color'|'pick'|'reject' }
|
||||
POST /photos/bulk/move Move files to a target folder_id
|
||||
POST /photos/bulk/copy Copy files to a target folder_id
|
||||
```
|
||||
|
||||
### 5.2 Folders
|
||||
|
||||
```
|
||||
GET /folders Folder tree (nested, with photo_count)
|
||||
GET /folders/{id}/photos Photos in folder (supports same filters as /photos)
|
||||
POST /folders Create folder (creates directory on disk)
|
||||
PATCH /folders/{id} Rename folder (renames directory on disk)
|
||||
DELETE /folders/{id} Delete folder — requires folder to be empty
|
||||
POST /folders/{id}/scan Trigger manual re-scan of folder
|
||||
```
|
||||
|
||||
### 5.3 Heaps
|
||||
|
||||
```
|
||||
GET /heaps List all heaps
|
||||
POST /heaps Create heap { name }
|
||||
GET /heaps/{id} Get heap with photos
|
||||
PATCH /heaps/{id} Rename heap
|
||||
DELETE /heaps/{id} Delete heap (does NOT delete photos)
|
||||
POST /heaps/{id}/photos Add photos { photo_ids: [] }
|
||||
DELETE /heaps/{id}/photos Remove photos { photo_ids: [] }
|
||||
POST /heaps/{id}/convert Convert heap to folder on disk: { target_path, move: bool }
|
||||
```
|
||||
|
||||
### 5.4 Tags
|
||||
|
||||
```
|
||||
GET /tags List all tags with usage counts
|
||||
POST /tags Create tag
|
||||
PATCH /tags/{id} Rename / recolor tag
|
||||
DELETE /tags/{id} Delete tag (removes from all photos)
|
||||
GET /tags/{id}/photos Photos with this tag
|
||||
```
|
||||
|
||||
### 5.5 Filters & Search
|
||||
|
||||
All list endpoints support these query parameters:
|
||||
|
||||
```
|
||||
q Full-text search (filename, user_title, user_notes, EXIF JSON)
|
||||
date_from ISO8601 datetime
|
||||
date_to ISO8601 datetime
|
||||
folder_id Filter by folder (recursive if include_subfolders=true)
|
||||
tag_ids Comma-separated tag IDs (AND logic by default; mode=or for OR)
|
||||
media_type photo|video|raw|heic (comma-separated for multiple)
|
||||
rating_min 0-5
|
||||
rating_max 0-5
|
||||
color_label red|orange|yellow|green|blue|purple|none
|
||||
is_picked true|false
|
||||
is_rejected true|false
|
||||
is_trashed true|false (default false)
|
||||
heap_id Filter to photos in a specific heap
|
||||
sort taken_at|added_at|filename|file_size|rating (default taken_at)
|
||||
order asc|desc (default desc)
|
||||
page integer (default 1)
|
||||
per_page integer (default 100, max 500)
|
||||
```
|
||||
|
||||
Full-text search uses SQLite FTS5. Create a virtual FTS table:
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE photos_fts USING fts5(
|
||||
photo_id UNINDEXED,
|
||||
filename,
|
||||
user_title,
|
||||
user_notes,
|
||||
exif_text -- denormalized key EXIF fields as plain text (camera make/model, GPS, lens, etc.)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.6 Trash
|
||||
|
||||
```
|
||||
GET /trash List trashed photos (same filters)
|
||||
POST /trash/restore Restore { photo_ids: [] } — moves files back to original folder
|
||||
DELETE /trash/empty Permanently delete all trashed photos + files
|
||||
DELETE /trash/{id} Permanently delete single photo + file
|
||||
```
|
||||
|
||||
### 5.7 Library Stats & Scanning
|
||||
|
||||
```
|
||||
GET /library/stats { total_photos, total_videos, total_size, last_scan }
|
||||
POST /library/scan Trigger full re-scan (Celery task)
|
||||
GET /library/scan/status { status, progress, current_folder, queued, done }
|
||||
```
|
||||
|
||||
### 5.8 Background Tasks (Celery)
|
||||
|
||||
- `scan_folder(folder_path)` — Walk directory tree, insert/update photos, detect deletions
|
||||
- `generate_thumbs(photo_id)` — Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpeg
|
||||
- `extract_metadata(photo_id)` — Run ExifTool, parse EXIF/XMP/IPTC, update DB
|
||||
- `watch_folders()` — Long-running Watchfiles task, dispatches scan_folder on changes
|
||||
- `embed_photo(photo_id)` *(placeholder, no-op)* — Reserved for CLIP embeddings
|
||||
|
||||
**Priority queues**: Thumbnail generation for visible photos should be on a `high` queue; full library scans on a `low` queue.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Architecture
|
||||
|
||||
### 6.1 Layout
|
||||
|
||||
Three-pane layout (similar to Lightroom Library module):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TOP BAR [Logo] [Search] [Filters bar] [View mode] [Heap] │
|
||||
├──────────┬──────────────────────────────────┬───────────────┤
|
||||
│ │ │ │
|
||||
│ LEFT │ MAIN TIMELINE │ RIGHT │
|
||||
│ SIDEBAR │ (continuous scroll, │ SIDEBAR │
|
||||
│ │ sticky date headers, │ (metadata │
|
||||
│ Folder │ virtualized thumbnail │ panel for │
|
||||
│ tree │ grid) │ selected │
|
||||
│ │ │ photo) │
|
||||
│ Heaps │ │ │
|
||||
│ list │ │ │
|
||||
│ │ │ │
|
||||
│ Tags │ │ │
|
||||
└──────────┴──────────────────────────────────┴───────────────┘
|
||||
```
|
||||
|
||||
- Left sidebar: resizable, collapsible (shortcut: `Tab`)
|
||||
- Right sidebar: collapsible (shortcut: `I`), shows when ≥1 photo selected
|
||||
- Main area: full virtualized scroll, single scroll region
|
||||
|
||||
### 6.2 Views
|
||||
|
||||
| View | Shortcut | Description |
|
||||
|------|----------|-------------|
|
||||
| Grid (Library) | `G` | Default timeline thumbnail grid |
|
||||
| Loupe (Fullscreen) | `E` | Single photo full-viewport view |
|
||||
| Compare | `C` | Side-by-side compare of 2 selected photos |
|
||||
|
||||
### 6.3 Timeline View (Grid)
|
||||
|
||||
- **Continuous vertical scroll** with **sticky date headers** that label each date group (Year / Month / Day — configurable via a "Group by" dropdown: Year, Month, Day, Week, Folder)
|
||||
- Thumbnails rendered via **TanStack Virtual** — only DOM nodes in/near viewport are rendered
|
||||
- Thumbnail grid is **responsive** — uses CSS grid with `auto-fill` and configurable thumbnail size (slider or `+/-` keys)
|
||||
- Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars
|
||||
- **Lazy thumbnail loading**: request `thumb_small` initially; upgrade to `thumb_medium` on hover/selection
|
||||
- On initial scan, show a shimmer skeleton for photos without thumbnails yet; poll backend for thumb completion
|
||||
|
||||
### 6.4 Keyboard Shortcuts (Lightroom-style)
|
||||
|
||||
#### Navigation (Grid mode)
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` `↑` `↓` | Move cursor one photo in direction |
|
||||
| `Shift+←/→/↑/↓` | Extend selection |
|
||||
| `Cmd/Ctrl+A` | Select all |
|
||||
| `Cmd/Ctrl+Shift+A` | Deselect all |
|
||||
| `Space` | Quick preview (fullscreen loupe, hold) |
|
||||
| `Enter` | Open loupe view |
|
||||
| `Esc` | Deselect / close loupe |
|
||||
| `Home` / `End` | Jump to first / last photo |
|
||||
| `Page Up/Down` | Scroll by screen height |
|
||||
|
||||
#### Navigation (Loupe mode)
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` | Previous / next photo |
|
||||
| `Esc` | Return to grid |
|
||||
| `Z` | Toggle zoom (fit ↔ 100%) |
|
||||
| `+` / `-` | Zoom in / out |
|
||||
|
||||
#### Flagging & Rating
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `P` | Pick (flag) |
|
||||
| `X` | Reject |
|
||||
| `U` | Unflag |
|
||||
| `1–5` | Set star rating |
|
||||
| `0` | Remove star rating |
|
||||
| `6` | Red label |
|
||||
| `7` | Orange label |
|
||||
| `8` | Yellow label |
|
||||
| `9` | Green label |
|
||||
|
||||
#### Actions
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `G` | Go to grid view |
|
||||
| `E` | Go to loupe view |
|
||||
| `C` | Compare view (2 selected) |
|
||||
| `Tab` | Toggle left sidebar |
|
||||
| `I` | Toggle right metadata sidebar |
|
||||
| `\` | Toggle filter bar |
|
||||
| `F` | Toggle fullscreen |
|
||||
| `Delete` | Move selected to trash |
|
||||
| `Shift+Delete` | Permanently delete (if in trash view) |
|
||||
| `Cmd/Ctrl+Z` | Undo last action |
|
||||
| `Cmd/Ctrl+Shift+Z` | Redo |
|
||||
| `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) |
|
||||
| `Cmd/Ctrl+X` | Cut selected (for move) |
|
||||
| `Cmd/Ctrl+V` | Paste into current folder |
|
||||
| `T` | Add/remove from active heap |
|
||||
| `Cmd/Ctrl+F` | Focus search bar |
|
||||
| `/` | Focus search bar |
|
||||
| `?` | Show keyboard shortcut reference overlay |
|
||||
|
||||
All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea.
|
||||
|
||||
### 6.5 Bulk Selection
|
||||
|
||||
- **Click** — select single photo (deselects others)
|
||||
- **Shift+Click** — range select from last selected to clicked
|
||||
- **Cmd/Ctrl+Click** — toggle individual photo in selection
|
||||
- **Cmd/Ctrl+A** — select all visible
|
||||
- A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export
|
||||
- Bulk actions call `POST /api/v1/photos/bulk`
|
||||
|
||||
### 6.6 Metadata Sidebar (Right Panel)
|
||||
|
||||
When a photo is selected, the right sidebar shows:
|
||||
|
||||
**Section: Preview**
|
||||
- Large thumbnail (clicking opens loupe)
|
||||
- Filename (editable inline, renames file on disk)
|
||||
- User title (editable)
|
||||
- User notes (textarea)
|
||||
- Rating (5-star widget, keyboard-interactive)
|
||||
- Color label (color dot picker)
|
||||
- Flags: Picked / Rejected toggles
|
||||
|
||||
**Section: Tags**
|
||||
- Tag chips with remove button
|
||||
- "Add tag" autocomplete input
|
||||
- Create new tag inline
|
||||
|
||||
**Section: EXIF / Metadata**
|
||||
Collapsible groups:
|
||||
- *Camera*: Make, Model, Serial, Lens, Firmware
|
||||
- *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode
|
||||
- *File*: Format, Dimensions, File Size, Color Space, Bit Depth
|
||||
- *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand)
|
||||
- *IPTC/XMP*: Copyright, Creator, Description, Keywords
|
||||
|
||||
**Section: Histogram** (stretch goal)
|
||||
- Live RGB+Luminosity histogram rendered from a downsampled version of the photo
|
||||
|
||||
### 6.7 Filter Bar
|
||||
|
||||
A collapsible horizontal bar below the top bar (shortcut `\`). Contains:
|
||||
|
||||
| Control | Type |
|
||||
|---------|------|
|
||||
| Date range | Date range picker (from/to) |
|
||||
| Media type | Multi-select chips: Photo / Video / RAW / HEIC |
|
||||
| Rating | Min/max star slider |
|
||||
| Color label | Color dot multi-select |
|
||||
| Flags | Picked / Rejected / Unflagged toggle buttons |
|
||||
| Tags | Multi-select tag dropdown (AND/OR mode toggle) |
|
||||
| Camera make | Dropdown (populated from DB) |
|
||||
| Lens | Dropdown (populated from DB) |
|
||||
|
||||
Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks.
|
||||
|
||||
### 6.8 Search
|
||||
|
||||
- Magnifier icon in top bar, shortcut `/` or `Cmd+F`
|
||||
- Full-text search via FTS5 backend
|
||||
- Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags
|
||||
- Results appear inline in the current view (no separate search results page)
|
||||
- Search combined with active filters (additive)
|
||||
|
||||
### 6.9 Folder Tree (Left Sidebar)
|
||||
|
||||
- Hierarchical tree view of all source roots and their subfolder structure
|
||||
- Each folder shows photo count badge
|
||||
- Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path
|
||||
- Drag-and-drop folders to rearrange (moves directory on disk with confirmation)
|
||||
- "All Photos" virtual root node at top
|
||||
- "Trash" virtual node at bottom with count badge
|
||||
|
||||
### 6.10 Heaps Panel (Left Sidebar)
|
||||
|
||||
- List of named heaps below folder tree
|
||||
- "+ New Heap" button (creates unnamed heap, prompts for name)
|
||||
- Each heap shows photo count
|
||||
- Click heap → main area shows heap contents in grid
|
||||
- Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap
|
||||
- **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap.
|
||||
- A persistent "current heap" pill shown in the top bar when a heap is active
|
||||
|
||||
### 6.11 Loupe View
|
||||
|
||||
- Single photo, full-viewport
|
||||
- Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display)
|
||||
- Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+
|
||||
- Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable
|
||||
- Left panel collapse, right metadata panel still accessible
|
||||
- For videos: HTML5 `<video>` player with controls, muted autoplay of preview, unmute toggle
|
||||
|
||||
### 6.12 Trash View
|
||||
|
||||
- Accessible via "Trash" node in sidebar
|
||||
- Same grid layout, same filters, same shortcuts
|
||||
- Extra actions in bulk selection bar: Restore, Permanently Delete
|
||||
- "Empty Trash" button at top with confirmation dialog showing count + total size
|
||||
|
||||
### 6.13 Library Scan Progress
|
||||
|
||||
- On first launch or manual scan trigger: a non-blocking progress bar in the top bar
|
||||
- Shows: `Scanning… 1,234 / 12,456 photos indexed`
|
||||
- Photos appear in the timeline as they are indexed (optimistic streaming via polling `GET /library/scan/status` every 2s)
|
||||
|
||||
---
|
||||
|
||||
## 7. Media Handling
|
||||
|
||||
### 7.1 Supported Formats
|
||||
|
||||
| Category | Formats |
|
||||
|----------|---------|
|
||||
| JPEG | `.jpg`, `.jpeg` |
|
||||
| PNG | `.png` |
|
||||
| TIFF | `.tif`, `.tiff` |
|
||||
| WebP | `.webp` |
|
||||
| HEIC/HEIF | `.heic`, `.heif` (via pillow-heif) |
|
||||
| RAW — Canon | `.cr2`, `.cr3` |
|
||||
| RAW — Nikon | `.nef`, `.nrw` |
|
||||
| RAW — Sony | `.arw`, `.srf` |
|
||||
| RAW — Fuji | `.raf` |
|
||||
| RAW — Panasonic | `.rw2` |
|
||||
| RAW — Olympus | `.orf` |
|
||||
| RAW — Samsung | `.srw` |
|
||||
| RAW — Pentax | `.pef` |
|
||||
| RAW — Leica | `.rwl`, `.dng` |
|
||||
| RAW — DNG (universal) | `.dng` |
|
||||
| RAW — Others | via rawpy (libraw) fallback |
|
||||
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.mts`, `.m2ts`, `.3gp` |
|
||||
| Live Photos | `.heic` + `.mov` pair (detect by matching base filename) |
|
||||
|
||||
### 7.2 Thumbnail Generation Pipeline
|
||||
|
||||
For each photo during indexing:
|
||||
1. Detect format by extension + magic bytes
|
||||
2. Decode to in-memory RGB image:
|
||||
- JPEG/PNG/TIFF/WebP → pyvips native
|
||||
- HEIC/HEIF → pillow-heif → pyvips
|
||||
- RAW → rawpy (half-size decode for speed) → numpy → pyvips
|
||||
- Video → ffmpeg extract frame at 10% duration → pyvips
|
||||
3. Auto-rotate by EXIF orientation
|
||||
4. Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
|
||||
5. Save as WebP (quality 85) to `/data/thumbs/{photo_id}/{size}.webp`
|
||||
6. Update `thumb_small`, `thumb_medium`, `thumb_large` columns in DB
|
||||
|
||||
For web display of original RAW/HEIC in loupe view: generate a full-res WebP proxy on demand (cached). Serve via `GET /photos/{id}/proxy`.
|
||||
|
||||
### 7.3 Metadata Extraction
|
||||
|
||||
Run ExifTool (subprocess) on every file during indexing. Parse output JSON. Store:
|
||||
- `taken_at` — prefer `DateTimeOriginal`, fallback: `CreateDate`, `MediaCreateDate`, file mtime
|
||||
- GPS coordinates if present
|
||||
- All EXIF/IPTC/XMP fields stored as JSON in `exif_json`
|
||||
- Denormalize key fields to FTS table for search
|
||||
|
||||
For Live Photos: link the `.mov` sidecar to the `.heic` via a `live_photo_video_id` FK on the photos table.
|
||||
|
||||
---
|
||||
|
||||
## 8. File Operations
|
||||
|
||||
All file operations that touch disk must:
|
||||
1. Validate target path is within a known source_root (prevent path traversal)
|
||||
2. Execute atomically where possible (temp file + rename)
|
||||
3. Update DB after successful disk operation (never before)
|
||||
4. Emit a WebSocket event (or SSE) so the frontend can update optimistically
|
||||
5. Be undoable via Undo stack (store reverse operation in memory, max 50 ops)
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Disk action | DB action |
|
||||
|-----------|-------------|-----------|
|
||||
| Move photos | `shutil.move` | Update `filepath`, `folder_id` |
|
||||
| Copy photos | `shutil.copy2` | Insert new photo record |
|
||||
| Rename file | `os.rename` | Update `filepath`, `filename` |
|
||||
| Rename folder | `os.rename` | Update folder `path` recursively |
|
||||
| Create folder | `os.makedirs` | Insert folder record |
|
||||
| Trash photo | Move to `/data/trash/{id}/original.{ext}` | Set `is_trashed=1`, `trashed_at` |
|
||||
| Restore from trash | Move back to original path (or new path if original gone) | Clear `is_trashed` |
|
||||
| Permanent delete | `os.unlink` | Delete photo record (cascade to tags, heaps) |
|
||||
| Convert heap to folder | `os.makedirs(target)` + move/copy each photo | Insert folder, update photo folder_id |
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance Requirements
|
||||
|
||||
- **Initial page load**: < 2s (LCP)
|
||||
- **Timeline scroll** (10,000+ photos): 60 fps — enforced by TanStack Virtual (only ~20-30 DOM nodes rendered at any time)
|
||||
- **Thumbnail serve**: < 50ms via Nginx X-Accel-Redirect (backend sets header, Nginx serves file directly)
|
||||
- **Search**: < 200ms for FTS5 query on 100k photos
|
||||
- **Thumbnail generation**: ≥ 10 photos/sec on typical homelab CPU (pyvips is ~10x faster than Pillow)
|
||||
- **Scan throughput**: ≥ 500 files/sec metadata scan (ExifTool batch mode processes files in bulk)
|
||||
- **Celery workers**: 4 concurrent workers by default (`CELERYD_CONCURRENCY=4` env var)
|
||||
- Images not yet thumbnailed show a shimmer skeleton; thumbnails stream into view as they complete
|
||||
|
||||
---
|
||||
|
||||
## 10. UI Design System
|
||||
|
||||
### 10.1 Aesthetic
|
||||
|
||||
Dark-first application (photography tools are dark-themed to preserve color perception). Light mode available via toggle.
|
||||
|
||||
- **Dark mode primary surface**: Near-black warm dark `#111110`, not cold gray
|
||||
- **Accent**: Desaturated teal — does not compete with photo colors
|
||||
- **Typography**: `Geist` (body, UI chrome) + `Geist Mono` (metadata values, EXIF numbers)
|
||||
- Dense UI — this is a power tool, not a consumer app. Compact spacing.
|
||||
- Inspired by: Lightroom Classic, Linear, Darkroom (iOS)
|
||||
|
||||
### 10.2 Key UI Components (shadcn/ui)
|
||||
|
||||
Use these shadcn/ui primitives: `Button`, `ContextMenu`, `Dialog`, `DropdownMenu`, `Input`, `Label`, `Popover`, `ScrollArea`, `Separator`, `Sheet` (for mobile sidebar), `Skeleton`, `Slider`, `Switch`, `Tabs`, `Textarea`, `Toast`, `Tooltip`
|
||||
|
||||
Build custom components:
|
||||
- `<PhotoThumbnail>` — thumbnail with selection state, pick/reject badges, rating overlay on hover
|
||||
- `<TimelineGroup>` — sticky date header + grid of thumbnails
|
||||
- `<VirtualTimeline>` — TanStack Virtual wrapper over TimelineGroups
|
||||
- `<FilmStrip>` — horizontal scrollable strip for loupe view
|
||||
- `<StarRating>` — interactive 0-5 stars
|
||||
- `<ColorLabel>` — 7-state color dot picker
|
||||
- `<MetadataRow>` — label + value pair with edit-in-place for editable fields
|
||||
- `<FolderTreeNode>` — recursive folder tree item with context menu
|
||||
- `<HeapItem>` — heap list item with active indicator
|
||||
- `<FilterChip>` — removable active filter chip
|
||||
- `<ProgressBar>` — scan progress in top bar
|
||||
- `<ShortcutReference>` — `?` overlay showing all shortcuts in a modal
|
||||
|
||||
### 10.3 Color Scheme Variables
|
||||
|
||||
```css
|
||||
/* Dark mode (default for photo apps) */
|
||||
--color-bg: #111110;
|
||||
--color-surface: #161615;
|
||||
--color-surface-2: #1c1c1a;
|
||||
--color-surface-offset: #222220;
|
||||
--color-border: rgba(255,255,255,0.08);
|
||||
--color-text: #e8e6e0;
|
||||
--color-text-muted: #878580;
|
||||
--color-text-faint: #4a4845;
|
||||
--color-primary: #4f98a3; /* desaturated teal */
|
||||
--color-pick: #4f9e5c; /* green for picked */
|
||||
--color-reject: #c25a5a; /* red for rejected */
|
||||
--color-star: #d4a340; /* amber for stars */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Error States & Edge Cases
|
||||
|
||||
- **File not found on disk** (moved externally): Show "missing file" badge on thumbnail. Offer "Locate File" action.
|
||||
- **Corrupt/unreadable file**: Log error, show broken-image placeholder, never crash the scan worker.
|
||||
- **Duplicate detection**: On scan, if a file with the same SHA-256 hash already exists in DB, mark as `is_duplicate=true` — do not create a second record. Show duplicate indicator in thumbnail.
|
||||
- **Scan in progress + user navigates**: Show partial results immediately as photos are indexed.
|
||||
- **Disk full**: Catch `OSError` on thumbnail write, log, continue scan.
|
||||
- **RAW decode failure**: Fall back to extracting the embedded JPEG preview from the RAW file (ExifTool can extract it).
|
||||
|
||||
---
|
||||
|
||||
## 12. Stretch Goals (Phase 2 — Not in Initial Build)
|
||||
|
||||
These must not be built now but the architecture must not block them:
|
||||
|
||||
1. **AI Scene Classification** — CLIP embeddings per photo, semantic search ("find photos with mountains")
|
||||
2. **Face Detection & Clustering** — face_recognition lib or InsightFace, cluster by identity
|
||||
3. **Smart Albums** — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
|
||||
4. **Duplicate Finder** — perceptual hash (pHash) across library
|
||||
5. **Export Presets** — resize + watermark + format conversion on export
|
||||
6. **Multi-user** — add FastAPI auth (JWT), per-user libraries
|
||||
7. **Mobile PWA** — service worker, offline thumbnail caching
|
||||
|
||||
---
|
||||
|
||||
## 13. Docker Compose File Structure
|
||||
|
||||
```
|
||||
photovault/
|
||||
├── docker-compose.yml
|
||||
├── photovault.yml ← user config
|
||||
├── .env ← PHOTO_DIRS, REDIS_URL, etc.
|
||||
├── frontend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── App.tsx
|
||||
│ ├── store/ ← Zustand stores
|
||||
│ ├── components/
|
||||
│ │ ├── layout/
|
||||
│ │ ├── timeline/
|
||||
│ │ ├── loupe/
|
||||
│ │ ├── sidebar/
|
||||
│ │ ├── metadata/
|
||||
│ │ └── shared/
|
||||
│ ├── hooks/
|
||||
│ ├── api/ ← TanStack Query hooks + axios client
|
||||
│ └── lib/
|
||||
│ └── shortcuts.ts ← global keyboard shortcut registry
|
||||
└── backend/
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── alembic/
|
||||
├── app/
|
||||
│ ├── main.py ← FastAPI app
|
||||
│ ├── config.py ← pydantic settings
|
||||
│ ├── database.py ← SQLAlchemy async engine
|
||||
│ ├── models/ ← SQLAlchemy ORM models
|
||||
│ ├── schemas/ ← Pydantic request/response schemas
|
||||
│ ├── routers/ ← FastAPI routers per domain
|
||||
│ │ ├── photos.py
|
||||
│ │ ├── folders.py
|
||||
│ │ ├── heaps.py
|
||||
│ │ ├── tags.py
|
||||
│ │ ├── trash.py
|
||||
│ │ └── library.py
|
||||
│ ├── services/ ← Business logic
|
||||
│ │ ├── scanner.py
|
||||
│ │ ├── thumbnailer.py
|
||||
│ │ ├── metadata.py
|
||||
│ │ └── file_ops.py
|
||||
│ └── tasks/ ← Celery tasks
|
||||
│ ├── celery.py
|
||||
│ ├── scan.py
|
||||
│ └── thumbs.py
|
||||
└── nginx.conf ← X-Accel-Redirect for thumb serving
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation Priorities
|
||||
|
||||
Build in this order to get a working MVP as fast as possible:
|
||||
|
||||
1. **Docker Compose skeleton** — all services up, health checks passing
|
||||
2. **DB schema + Alembic migration**
|
||||
3. **Folder scanner + thumbnail generator** (Celery tasks) — the core engine
|
||||
4. **`GET /photos` + `GET /photos/{id}/thumb/{size}`** — minimum API to display photos
|
||||
5. **Frontend: VirtualTimeline + PhotoThumbnail** — display the library
|
||||
6. **Frontend: keyboard navigation + selection**
|
||||
7. **Frontend: left sidebar (folder tree + heaps)**
|
||||
8. **Frontend: right sidebar (metadata panel) + EXIF display**
|
||||
9. **Filter bar + search**
|
||||
10. **Loupe view with filmstrip**
|
||||
11. **File operations: move, copy, rename, trash, restore**
|
||||
12. **Metadata editing: title, notes, rating, color label, tags**
|
||||
13. **Heaps: create, populate, convert to folder**
|
||||
14. **Trash view + permanent delete**
|
||||
15. **Polish: undo/redo, bulk actions, duplicate detection, live scan progress**
|
||||
13
sidecar/.dockerignore
Normal file
13
sidecar/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# Files that have no business shipping into the build context.
|
||||
# Anything not listed here gets COPY'd into /src so keep this tight.
|
||||
|
||||
# Local host-mode build output — re-built inside the image.
|
||||
mule-sidecar
|
||||
|
||||
# Runtime state from the M3 Node prototype.
|
||||
data/
|
||||
|
||||
# Docs + git noise.
|
||||
README.md
|
||||
.git/
|
||||
.gitignore
|
||||
28
sidecar/Dockerfile
Normal file
28
sidecar/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
#
|
||||
# mule-sidecar — Go service for endpoints PhotoPrism does not expose.
|
||||
# Multi-stage build: a Go toolchain image compiles a static binary,
|
||||
# then we copy it onto a distroless base so the runtime image is ~12 MB
|
||||
# with no shell, package manager, or libc.
|
||||
|
||||
FROM docker.io/library/golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache deps separately from source so a one-line code change doesn't
|
||||
# re-download the whole module graph.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . ./
|
||||
# CGO disabled → fully static binary that runs on the distroless base
|
||||
# (no libc resolution at startup). -trimpath strips local paths from
|
||||
# debug info; -s -w drop the symbol table to keep the binary small.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags='-s -w' \
|
||||
-o /out/mule-sidecar .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:latest
|
||||
COPY --from=build /out/mule-sidecar /mule-sidecar
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/mule-sidecar"]
|
||||
116
sidecar/README.md
Normal file
116
sidecar/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# mule-sidecar
|
||||
|
||||
Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not
|
||||
expose. Same wire contract as the M3 Node prototype it replaces; the
|
||||
SvelteKit client at [web/](../web/) talks to it transparently through
|
||||
Vite's `/api/sidecar/*` proxy.
|
||||
|
||||
## What it owns
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| ------ | --------------------------------------- | --------------------------------------------- |
|
||||
| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. |
|
||||
| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. |
|
||||
| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). |
|
||||
| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. |
|
||||
| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. |
|
||||
| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. |
|
||||
| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. |
|
||||
| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). |
|
||||
| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. |
|
||||
| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. |
|
||||
| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. |
|
||||
| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates/<ts>/`. |
|
||||
|
||||
Auth: every endpoint except `healthz` requires the caller's
|
||||
`X-Auth-Token` header. The sidecar holds no service credentials — it
|
||||
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
|
||||
to confirm the session is live before doing anything destructive.
|
||||
|
||||
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
|
||||
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
|
||||
PhotoPrism reindex of the affected parent in the background.
|
||||
|
||||
## Run
|
||||
|
||||
The sidecar is a `sidecar` service in the PhotoPrism compose stack.
|
||||
Bringing the whole stack up brings it up too:
|
||||
|
||||
```sh
|
||||
podman-compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.podman.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
This builds [Dockerfile](Dockerfile) (multi-stage `golang:1.25-alpine` →
|
||||
`gcr.io/distroless/static`, ~12 MB final image), starts the container,
|
||||
and binds `127.0.0.1:8000` to the service. The SvelteKit dev server
|
||||
proxies `/api/sidecar/*` to that port transparently.
|
||||
|
||||
### Dev-iteration loop (host build)
|
||||
|
||||
For tight iteration without rebuilding the image on every change you
|
||||
can run it as a host process — Go is already on the dev machine:
|
||||
|
||||
```sh
|
||||
cd sidecar
|
||||
go build -o mule-sidecar .
|
||||
|
||||
ORIGINALS_ROOT=/path/to/photoprism/originals \
|
||||
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||
SIDECAR_PORT=8000 \
|
||||
./mule-sidecar
|
||||
```
|
||||
|
||||
The host build connects to `mariadb` via the loopback port the compose
|
||||
file publishes; stop `pp-sidecar` first so they don't fight for 8000.
|
||||
|
||||
## Env
|
||||
|
||||
| Var | Default | Notes |
|
||||
| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. |
|
||||
| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. |
|
||||
| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. |
|
||||
| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. |
|
||||
| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. |
|
||||
| `SIDECAR_DB_PORT` | `3306` | |
|
||||
| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. |
|
||||
| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. |
|
||||
| `SIDECAR_DB_NAME` | `mule_sidecar` | |
|
||||
|
||||
## Schema
|
||||
|
||||
GORM `AutoMigrate` creates the only table the service owns:
|
||||
|
||||
```sql
|
||||
CREATE TABLE marks (
|
||||
photo_uid VARCHAR(64) PRIMARY KEY,
|
||||
rating BIGINT NULL,
|
||||
color VARCHAR(16) NULL,
|
||||
updated_at DATETIME(3)
|
||||
);
|
||||
```
|
||||
|
||||
The M3 Node prototype kept the same data in `sidecar/data/marks.json`.
|
||||
There is no migration path — the prototype's marks file was dev-only
|
||||
state. Heap-sharing tables (M4) will land in subsequent migrations.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
sidecar/
|
||||
├── Dockerfile multi-stage golang:1.25 → distroless/static
|
||||
├── main.go entrypoint, route wiring, graceful shutdown
|
||||
├── config.go env-driven Config
|
||||
├── db.go GORM open + Mark model + AutoMigrate
|
||||
├── auth.go requireSession middleware + ctxToken
|
||||
├── fs.go path safety, walk, sha1
|
||||
├── pp.go PhotoPrism HTTP client (validateSession, reindex)
|
||||
├── handlers_rename.go
|
||||
├── handlers_folders.go
|
||||
├── handlers_marks.go
|
||||
├── handlers_heap.go
|
||||
└── handlers_dups.go
|
||||
```
|
||||
87
sidecar/auth.go
Normal file
87
sidecar/auth.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// requireSession is the standard auth shim every mutating handler wears.
|
||||
// We don't store a shared service credential — the caller's X-Auth-Token
|
||||
// is the only authority, and we probe PhotoPrism with it before doing any
|
||||
// destructive work. The handler reads the validated token off the context
|
||||
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
||||
// operation. The resolved username is available via ctxUserName.
|
||||
func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Auth-Token")
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
||||
return
|
||||
}
|
||||
user := pp.resolveSession(c.Request.Context(), token)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
c.Set("token", token)
|
||||
c.Set("userName", user.UserName)
|
||||
c.Set("userUID", user.UserUID)
|
||||
c.Set("basePath", user.BasePath)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ctxToken returns the validated X-Auth-Token a previous requireSession
|
||||
// middleware stored on the request. Handlers MUST run behind that
|
||||
// middleware; otherwise this returns the empty string.
|
||||
func ctxToken(c *gin.Context) string {
|
||||
v, ok := c.Get("token")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserName returns the PhotoPrism username resolved by requireSession.
|
||||
func ctxUserName(c *gin.Context) string {
|
||||
v, ok := c.Get("userName")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
|
||||
func ctxUserUID(c *gin.Context) string {
|
||||
v, ok := c.Get("userUID")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
|
||||
func ctxBasePath(c *gin.Context) string {
|
||||
v, ok := c.Get("basePath")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
92
sidecar/config.go
Normal file
92
sidecar/config.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config aggregates every runtime knob the sidecar reads from the
|
||||
// environment. Held in one struct so the rest of the package can take a
|
||||
// pointer instead of poking os.Getenv at use-sites.
|
||||
type Config struct {
|
||||
OriginalsRoot string // absolute path to PhotoPrism's originals dir
|
||||
PhotoprismBaseURL string // e.g. http://localhost:2342
|
||||
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
|
||||
Port int // HTTP listen port
|
||||
DSN string // GORM/MySQL connection string for mule_sidecar
|
||||
// PpDSN is a second DB connection string pointed at PhotoPrism's own
|
||||
// schema (`photoprism.*`). Sidecar code that needs to mutate
|
||||
// PhotoPrism-managed rows (e.g. auth_users.base_path) opens its own
|
||||
// connection with these creds rather than asking for grants on the
|
||||
// mule_sidecar user. Empty if PP_DB_PASSWORD isn't provided, in
|
||||
// which case PP-touching features (user-basepath reconciler) stay
|
||||
// dormant.
|
||||
PpDSN string
|
||||
// UserBasepaths is the parsed `USER_BASEPATHS` env. Maps PhotoPrism
|
||||
// usernames to originals-relative base paths so OIDC-provisioned
|
||||
// users land with the right library scope without any admin
|
||||
// touching `photoprism users mod`.
|
||||
UserBasepaths map[string]string
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
root := envOr("ORIGINALS_ROOT", "/photoprism/originals")
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
portStr := envOr("SIDECAR_PORT", "8000")
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsn := os.Getenv("SIDECAR_DSN")
|
||||
if dsn == "" {
|
||||
// Default matches the user that mariadb/init/01-sidecar.sql provisions
|
||||
// on first boot. The literal placeholder password is intentional: the
|
||||
// SQL ships with it and an env-templating step is left for whoever
|
||||
// runs this in a non-local context.
|
||||
user := envOr("SIDECAR_DB_USER", "sidecar")
|
||||
pass := envOr("SIDECAR_DB_PASSWORD", "replace-at-m4-bringup")
|
||||
host := envOr("SIDECAR_DB_HOST", "127.0.0.1")
|
||||
dbPort := envOr("SIDECAR_DB_PORT", "3306")
|
||||
name := envOr("SIDECAR_DB_NAME", "mule_sidecar")
|
||||
dsn = user + ":" + pass + "@tcp(" + host + ":" + dbPort + ")/" + name +
|
||||
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||
}
|
||||
|
||||
// PhotoPrism schema connection — only used by the user-basepath
|
||||
// reconciler. Stays empty if PP_DB_PASSWORD isn't set, and callers
|
||||
// gate behaviour on that. We use PhotoPrism's own DB user rather
|
||||
// than the sidecar's because `mule_sidecar` has no grants on
|
||||
// `photoprism.*` (see mariadb/init/01-sidecar.sql).
|
||||
ppDSN := ""
|
||||
if ppPass := os.Getenv("PP_DB_PASSWORD"); ppPass != "" {
|
||||
ppUser := envOr("PP_DB_USER", "photoprism")
|
||||
ppHost := envOr("PP_DB_HOST", envOr("SIDECAR_DB_HOST", "mariadb"))
|
||||
ppPort := envOr("PP_DB_PORT", envOr("SIDECAR_DB_PORT", "3306"))
|
||||
ppName := envOr("PP_DB_NAME", "photoprism")
|
||||
ppDSN = ppUser + ":" + ppPass + "@tcp(" + ppHost + ":" + ppPort + ")/" + ppName +
|
||||
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
OriginalsRoot: abs,
|
||||
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
|
||||
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
|
||||
Port: port,
|
||||
DSN: dsn,
|
||||
PpDSN: ppDSN,
|
||||
UserBasepaths: parseUserBasepaths(os.Getenv("USER_BASEPATHS")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
63
sidecar/db.go
Normal file
63
sidecar/db.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Mark mirrors the per-photo extras the web client stores via the marks
|
||||
// endpoints — rating + four-colour label. Composite primary key
|
||||
// (photo_uid, user_name) so each user has independent marks. Both payload
|
||||
// fields are nullable so the sparse "no rating / no colour" state
|
||||
// round-trips cleanly.
|
||||
type Mark struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
||||
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
|
||||
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
|
||||
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// TableName pins the GORM-pluralised default to a name that matches the
|
||||
// other tables the M4 plan calls out (`marks`, `heap_shares`, …) so
|
||||
// nothing surprising lands in the schema.
|
||||
func (Mark) TableName() string { return "marks" }
|
||||
|
||||
// asJSON returns the wire shape clients expect — same flat object the
|
||||
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
|
||||
// as `{}` which the client treats as "no mark on this photo".
|
||||
func (m *Mark) asJSON() map[string]any {
|
||||
out := map[string]any{}
|
||||
if m == nil {
|
||||
return out
|
||||
}
|
||||
if m.Rating != nil {
|
||||
out["rating"] = *m.Rating
|
||||
}
|
||||
if m.Color != nil && *m.Color != "" {
|
||||
out["color"] = *m.Color
|
||||
}
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
// ISO-8601 with millisecond precision, UTC — matches the Node
|
||||
// prototype's `new Date().toISOString()` so clients written against
|
||||
// the old endpoint stay happy.
|
||||
out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openDB(dsn string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&Mark{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
213
sidecar/fs.go
Normal file
213
sidecar/fs.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sanitizeFilename trims a user-supplied filename and rejects anything
|
||||
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
|
||||
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
|
||||
// with most filename shapes; we lock down the ones a malicious or
|
||||
// careless caller might use to escape the folder.
|
||||
func sanitizeFilename(name string) (string, bool) {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" || len(s) > 240 {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(s, ".") {
|
||||
return "", false
|
||||
}
|
||||
if s == "." || s == ".." {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(s, "/\\\x00") {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// resolveUnderRoot takes a user-supplied relative path and returns its
|
||||
// absolute form, but only when the resolved location lives under the
|
||||
// configured originals root. Symlink escapes are caught by resolving the
|
||||
// parent through filepath.EvalSymlinks first.
|
||||
//
|
||||
// `mustExist=false` is for the *target* of a rename/create where the
|
||||
// terminal path isn't on disk yet; the parent still has to exist and
|
||||
// still has to be inside the root.
|
||||
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
|
||||
if rel == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
clean := strings.TrimLeft(rel, "/")
|
||||
if clean == "" || clean == "." {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
for _, seg := range strings.Split(clean, "/") {
|
||||
if seg == "" || seg == ".." {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
}
|
||||
abs := filepath.Join(root, clean)
|
||||
parent := filepath.Dir(abs)
|
||||
|
||||
parentReal, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !sameOrUnder(parentReal, root) {
|
||||
return "", errors.New("parent escapes originals root")
|
||||
}
|
||||
if mustExist {
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// ensureWithinOriginals checks that an absolute path's parent resolves to
|
||||
// somewhere inside the root after symlink evaluation. Used for the
|
||||
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
|
||||
func ensureWithinOriginals(root, absPath string) bool {
|
||||
real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sameOrUnder(real, root)
|
||||
}
|
||||
|
||||
func sameOrUnder(p, root string) bool {
|
||||
if p == root {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p, root+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// uniqueName resolves "destDir/basename" against collisions by appending
|
||||
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
|
||||
// can't pin the goroutine forever.
|
||||
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
|
||||
ext := filepath.Ext(basename)
|
||||
stem := strings.TrimSuffix(basename, ext)
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidate := basename
|
||||
if i > 0 {
|
||||
candidate = stem + "-" + itoa(i) + ext
|
||||
}
|
||||
p := filepath.Join(destDir, candidate)
|
||||
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
|
||||
return p, candidate, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// itoa is the tiny stdlib-free formatter we use inside hot loops.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// fileEntry is the per-file row walkFiles emits. relPath stays root-
|
||||
// relative so it can land in API responses unchanged.
|
||||
type fileEntry struct {
|
||||
RelPath string
|
||||
AbsPath string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
|
||||
// itself walks the same set; we keep the list in lock-step so callers
|
||||
// don't see "duplicate" warnings about files PhotoPrism would ignore.
|
||||
var supportedExts = map[string]struct{}{
|
||||
".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
|
||||
".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
|
||||
".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
|
||||
".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
|
||||
".orf": {}, ".rw2": {}, ".raw": {},
|
||||
}
|
||||
|
||||
// walkFiles enumerates every supported media file under root, skipping
|
||||
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
|
||||
// folder). Errors on individual entries are swallowed so a single
|
||||
// permission-denied dir doesn't abort the whole scan.
|
||||
func walkFiles(root string) ([]fileEntry, error) {
|
||||
var out []fileEntry
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// Permission errors etc. — skip the offending subtree but
|
||||
// keep walking. The dup-scan endpoint is best-effort.
|
||||
if d != nil && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if p != root && strings.HasPrefix(name, ".") {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if _, ok := supportedExts[ext]; !ok {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, fileEntry{
|
||||
RelPath: rel,
|
||||
AbsPath: p,
|
||||
Size: info.Size(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
|
||||
// doesn't blow the process's RAM. Returns the hex digest.
|
||||
func sha1File(absPath string) (string, error) {
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha1.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
45
sidecar/go.mod
Normal file
45
sidecar/go.mod
Normal file
@@ -0,0 +1,45 @@
|
||||
module mule-sidecar
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
101
sidecar/go.sum
Normal file
101
sidecar/go.sum
Normal file
@@ -0,0 +1,101 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
230
sidecar/handlers_dups.go
Normal file
230
sidecar/handlers_dups.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const quarantineDir = ".duplicates"
|
||||
|
||||
type dupFileLite struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type dupGroup struct {
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
IndexedPath *string `json:"indexedPath"`
|
||||
Files []dupFileLite `json:"files"`
|
||||
}
|
||||
|
||||
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||||
// looking up "which file path has this hash already indexed", used to
|
||||
// hint the UI which copy to keep.
|
||||
type dupListPhoto struct {
|
||||
Files []ppFile `json:"Files"`
|
||||
}
|
||||
|
||||
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
start := time.Now()
|
||||
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
|
||||
|
||||
all, err := walkFiles(cfg.OriginalsRoot)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Group by size first: byte-identical files necessarily share size,
|
||||
// so size-collision is a cheap O(N) prefilter that lets us skip
|
||||
// hashing >95% of a typical library.
|
||||
bySize := map[int64][]fileEntry{}
|
||||
for _, f := range all {
|
||||
bySize[f.Size] = append(bySize[f.Size], f)
|
||||
}
|
||||
|
||||
// Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS
|
||||
// so we don't drown the disk with parallel reads on a spinning HDD.
|
||||
type hashOut struct {
|
||||
hash string
|
||||
f fileEntry
|
||||
}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, 4)
|
||||
outMu sync.Mutex
|
||||
byHash = map[string][]fileEntry{}
|
||||
hashSize = map[string]int64{}
|
||||
)
|
||||
for size, group := range bySize {
|
||||
if len(group) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, f := range group {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(f fileEntry, sz int64) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
h, err := sha1File(f.AbsPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
outMu.Lock()
|
||||
byHash[h] = append(byHash[h], f)
|
||||
hashSize[h] = sz
|
||||
outMu.Unlock()
|
||||
}(f, size)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Drop singletons (size collision but different hashes), then ask
|
||||
// PhotoPrism which of the duplicates it has indexed so the UI can
|
||||
// default the "keep" selection to that one.
|
||||
groups := make([]dupGroup, 0)
|
||||
for h, files := range byHash {
|
||||
if len(files) < 2 {
|
||||
continue
|
||||
}
|
||||
g := dupGroup{Hash: h, Size: hashSize[h]}
|
||||
for _, f := range files {
|
||||
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
|
||||
}
|
||||
// Best-effort lookup; swallow errors. The hash query is cheap on
|
||||
// PhotoPrism's side (indexed column).
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||||
"/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil)
|
||||
if err == nil && resp.OK {
|
||||
var photos []dupListPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 {
|
||||
if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" {
|
||||
p := pf.Name
|
||||
g.IndexedPath = &p
|
||||
}
|
||||
}
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
// Sort by reclaimable bytes descending (size × duplicate-count) so
|
||||
// the biggest wins float to the top of the UI.
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].Size*int64(len(groups[i].Files)-1) >
|
||||
groups[j].Size*int64(len(groups[j].Files)-1)
|
||||
})
|
||||
|
||||
ms := time.Since(start).Milliseconds()
|
||||
slog.Info("dup.scan done", "groups", len(groups), "ms", ms)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"groups": groups,
|
||||
"scannedMs": ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type dupArchiveBody struct {
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
type dupMoved struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
type dupArchiveErr struct {
|
||||
Path string `json:"path"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
var body dupArchiveBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Each archive batch lands in its own timestamped subdir so the
|
||||
// user can browse what was quarantined when (and recover by hand
|
||||
// if they change their mind).
|
||||
stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
|
||||
targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp)
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// archiveOne moves a single file into the quarantine batch dir
|
||||
// and returns the new relative path. Disambiguates same-basename
|
||||
// collisions within the batch so two `IMG_0001.jpg` from
|
||||
// different folders don't clobber each other.
|
||||
archiveOne := func(rel string) (string, error) {
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid path")
|
||||
}
|
||||
base := filepath.Base(abs)
|
||||
dest := filepath.Join(targetDir, base)
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stem := base[:len(base)-len(filepath.Ext(base))]
|
||||
dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base))
|
||||
}
|
||||
if err := os.Rename(abs, dest); err != nil {
|
||||
// EXDEV fallback — copy+remove for libraries that span
|
||||
// filesystems (e.g. originals on a different mount).
|
||||
if err2 := copyFile(abs, dest); err2 != nil {
|
||||
return "", err
|
||||
}
|
||||
if err2 := os.Remove(abs); err2 != nil {
|
||||
return "", errors.New("moved but source remove failed: " + err2.Error())
|
||||
}
|
||||
}
|
||||
relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest)
|
||||
return relDest, nil
|
||||
}
|
||||
|
||||
moved := []dupMoved{}
|
||||
errs := []dupArchiveErr{}
|
||||
for _, rel := range body.Paths {
|
||||
relDest, err := archiveOne(rel)
|
||||
if err != nil {
|
||||
errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
moved = append(moved, dupMoved{From: rel, To: relDest})
|
||||
slog.Info("dup.archive", "from", rel, "to", relDest)
|
||||
}
|
||||
|
||||
// Reindex the entire library so PhotoPrism drops rows for the
|
||||
// archived files. cleanup:true is critical — the files still
|
||||
// exist on disk, just under .duplicates/ which the indexer
|
||||
// ignores.
|
||||
if len(moved) > 0 {
|
||||
go func() {
|
||||
if err := pp.reindex(context.Background(), token, "/"); err != nil {
|
||||
slog.Warn("dup.archive reindex failed", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
|
||||
}
|
||||
}
|
||||
307
sidecar/handlers_folders.go
Normal file
307
sidecar/handlers_folders.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// pathParam pulls the URL-encoded :rel out of the Gin context and
|
||||
// unescapes it. UseRawPath is on at the router level (see main.go) so the
|
||||
// raw value still carries `%2F` for nested paths; we decode here.
|
||||
func pathParam(c *gin.Context, key string) (string, bool) {
|
||||
raw := c.Param(key)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
dec, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
type folderCreateBody struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
var body folderCreateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||||
return
|
||||
}
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.Mkdir(abs, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
|
||||
slog.Info("folder.create", "path", rel)
|
||||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||||
}
|
||||
}
|
||||
|
||||
type folderRenameBody struct {
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
rel, ok := pathParam(c, "rel")
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
var body folderRenameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
newName, ok := sanitizeFilename(body.NewName)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
|
||||
return
|
||||
}
|
||||
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
|
||||
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
|
||||
slog.Info("folder.rename", "from", oldRel, "to", newRel)
|
||||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"oldPath": oldRel,
|
||||
"newPath": newRel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
rel, ok := pathParam(c, "rel")
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if abs == cfg.OriginalsRoot {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("folder.delete", "path", rel)
|
||||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||||
}
|
||||
}
|
||||
|
||||
type folderCountsBody struct {
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
// folderCountsRow is the minimal PhotoPrism photo projection the handler
|
||||
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
|
||||
// expands one photo into one row per File on disk). PhotoPrism returns a
|
||||
// JSON array of much richer objects; unmarshalling into this small
|
||||
// shape ignores everything we don't care about.
|
||||
type folderCountsRow struct {
|
||||
UID string `json:"UID"`
|
||||
}
|
||||
|
||||
// handleFolderCounts returns photo counts for each PhotoPrism folder
|
||||
// path in one round-trip. The web client used to fire one
|
||||
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
|
||||
// folder × N folders) to populate the left-sidebar tree. Moving the
|
||||
// fan-out into the sidecar keeps the same correctness profile — same
|
||||
// q-DSL, same `merged=false` UID dedupe — but the wire payload back
|
||||
// to the browser collapses to a single small JSON object
|
||||
// (`{path: count}`).
|
||||
//
|
||||
// We bounce off PhotoPrism with paginated `count=1000` calls and dedupe
|
||||
// UIDs server-side rather than trusting a count header: PhotoPrism's
|
||||
// `/photos` X-Count is the *per-page* row count (per existing front-end
|
||||
// comment), not the total-match count, so we'd silently undercount any
|
||||
// folder with more than 1000 files. The loop walks offsets until PP
|
||||
// returns a short page, so the result is correct regardless of folder
|
||||
// size (until the wider fan-out becomes the bottleneck, which is many
|
||||
// orders of magnitude away on this hardware).
|
||||
//
|
||||
// Bounded concurrency caps the fan-out so a library with hundreds of
|
||||
// folders doesn't open hundreds of connections to PhotoPrism at once.
|
||||
// Errors per-folder degrade to count=0 rather than failing the whole
|
||||
// batch — the sidebar would rather show a missing badge for one folder
|
||||
// than nothing for any.
|
||||
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
var body folderCountsBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if len(body.Paths) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
}
|
||||
|
||||
const maxInFlight = 8
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, maxInFlight)
|
||||
mu sync.Mutex
|
||||
counts = make(map[string]int, len(body.Paths))
|
||||
)
|
||||
// Seed every input key so the response always carries the same
|
||||
// shape the client posted, even for paths whose lookup failed.
|
||||
for _, p := range body.Paths {
|
||||
counts[p] = 0
|
||||
}
|
||||
for _, p := range body.Paths {
|
||||
path := p
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
// `path:<x>` is an exact match in PhotoPrism's q-DSL —
|
||||
// it matches only photos whose `photo_path` field equals
|
||||
// <x>, not descendants. The indexer always nests photos
|
||||
// under YYYY/MM, so an internal tree node like `2024` has
|
||||
// zero direct children and reports a count of 0 unless we
|
||||
// recurse. `path:<x>*` is the documented wildcard form and
|
||||
// matches both `<x>` itself (no harm if empty) and every
|
||||
// `<x>/...` descendant.
|
||||
//
|
||||
// `merged=false` still returns one row per File on disk,
|
||||
// so HEIC + companion JPG count twice unless we dedupe by
|
||||
// UID — which is what the old client-side code did, and
|
||||
// what we keep doing here.
|
||||
q := url.QueryEscape(`path:"` + path + `*"`)
|
||||
const pageSize = 1000
|
||||
seen := make(map[string]struct{})
|
||||
for offset := 0; ; offset += pageSize {
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=false&q=%s", pageSize, offset, q),
|
||||
token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
slog.Warn("folder.counts: pp call failed",
|
||||
"path", path,
|
||||
"offset", offset,
|
||||
"err", err,
|
||||
"status", func() int {
|
||||
if resp != nil {
|
||||
return resp.Status
|
||||
}
|
||||
return 0
|
||||
}())
|
||||
return
|
||||
}
|
||||
var rows []folderCountsRow
|
||||
if err := json.Unmarshal(resp.Body, &rows); err != nil {
|
||||
slog.Warn("folder.counts: parse failed", "path", path, "offset", offset, "err", err)
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.UID == "" {
|
||||
continue
|
||||
}
|
||||
seen[r.UID] = struct{}{}
|
||||
}
|
||||
if len(rows) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
counts[path] = len(seen)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
}
|
||||
|
||||
// fireReindex wraps pp.reindex with logging and a detached context so
|
||||
// it can run in a goroutine after the response has gone out. The Node
|
||||
// prototype kicks reindex with `void reindex(...)` and never awaits;
|
||||
// matching that here keeps the apparent latency of mutating endpoints
|
||||
// low (PhotoPrism's index can take seconds on a big folder).
|
||||
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
|
||||
// pp.call's client already enforces a 60s timeout, so the parent
|
||||
// context can be detached from the request — the handler has long
|
||||
// since written its response.
|
||||
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
|
||||
slog.Warn("reindex failed", "path", parentRel, "err", err)
|
||||
}
|
||||
}
|
||||
70
sidecar/handlers_folders_proxy.go
Normal file
70
sidecar/handlers_folders_proxy.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleFoldersProxy proxies PhotoPrism's /api/v1/folders/originals and
|
||||
// post-filters by the caller's BasePath so the folder tree only shows
|
||||
// folders under the user's library root.
|
||||
//
|
||||
// Route: GET /api/sidecar/folders (behind requireSession)
|
||||
func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward query params to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
if query == "" {
|
||||
query = "recursive=true&uncached=true&files=false"
|
||||
}
|
||||
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/folders/originals?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream folders request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the response — PhotoPrism returns { folders: [...] }.
|
||||
var payload struct {
|
||||
Folders []map[string]any `json:"folders"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &payload); err != nil {
|
||||
c.Data(resp.Status, "application/json", resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return as-is.
|
||||
if basePath == "" {
|
||||
c.JSON(http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/"
|
||||
|
||||
// Post-filter folders by Path field only — frontend handles BasePath
|
||||
// prefix stripping via toUserPath().
|
||||
filtered := make([]map[string]any, 0, len(payload.Folders))
|
||||
for _, f := range payload.Folders {
|
||||
rawPath, ok := f["Path"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pathStr, ok := rawPath.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Keep only folders under the user's base path.
|
||||
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user