Compare commits
182 Commits
a4f64fad58
...
feat/gpu-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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
|
||||
135
.env.example
135
.env.example
@@ -1,83 +1,70 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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 ────────────────────────────────────────────────────────────────────
|
||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# 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).
|
||||
#
|
||||
# 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=*
|
||||
|
||||
|
||||
# ── LOGGING / TIMEZONE ───────────────────────────────────────────────────────
|
||||
|
||||
# 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/backend/yolov8n.pt
|
||||
|
||||
330
README.md
330
README.md
@@ -1,230 +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 GPS mapping
|
||||
- **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 — each with a card-grid browse view that drills into a full Timeline detail
|
||||
- **Dark Mode**: Photography-optimized dark interface
|
||||
- **Vision Pipeline**: YOLO object detection, OCR text extraction, CLIP embeddings for semantic search, InsightFace face detection and clustering
|
||||
- **People View**: Browse identified people as cards, click to see all photos of a person
|
||||
- **Map View**: Browse GPS-tagged photos on an interactive Leaflet map
|
||||
- **Duplicate Detection**: Perceptual hash-based duplicate grouping with best-pick UI
|
||||
- **Semantic Search**: Natural-language photo search powered by CLIP embeddings
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Python 3.12 with FastAPI
|
||||
- PostgreSQL + pgvector with SQLAlchemy (async) and Alembic migrations
|
||||
- Celery + Redis for background tasks
|
||||
- pyvips for fast thumbnail generation
|
||||
- ExifTool for metadata extraction
|
||||
- ONNX Runtime for vision models (YOLO, CLIP, InsightFace)
|
||||
|
||||
### 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 (thumbnails, metadata, vision pipeline)
|
||||
- **redis**: Message broker for Celery
|
||||
- **db**: PostgreSQL with pgvector extension (for CLIP/face embeddings)
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` `↑` `↓` | Navigate photos |
|
||||
| `Space` | Quick preview |
|
||||
| `Enter` | Open loupe view |
|
||||
| `T` | Add to active heap |
|
||||
| `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
|
||||
- PostgreSQL full-text search with tsvector indexing
|
||||
- pgvector for fast nearest-neighbor embedding search
|
||||
## Read-only libraries
|
||||
|
||||
## Future Features
|
||||
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:
|
||||
|
||||
- Smart albums (auto-populated by saved filters)
|
||||
- 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 /data/models /app/config
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -1,48 +0,0 @@
|
||||
# Alembic configuration for PhotoVault.
|
||||
#
|
||||
# The actual database URL is loaded at runtime by alembic/env.py from the
|
||||
# DATABASE_URL environment variable (with the async driver suffix stripped).
|
||||
# The placeholder below is only used for `alembic revision --autogenerate`
|
||||
# when no env var is set.
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+psycopg2://mulita:mulita@localhost:5432/mulita
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Alembic environment for PhotoVault.
|
||||
|
||||
Pulls DATABASE_URL from the environment so the same migrations work in
|
||||
docker compose and locally. Strips the async driver suffix because Alembic
|
||||
runs synchronously via psycopg2.
|
||||
|
||||
Future-migration note
|
||||
---------------------
|
||||
Fresh installs run `Base.metadata.create_all` in `app.database.init_db`
|
||||
*before* migrations would normally apply, so any migration that adds a
|
||||
column / index / table to an object the model already declares will see
|
||||
that object already present. Write migrations defensively:
|
||||
|
||||
op.execute("ALTER TABLE photos ADD COLUMN IF NOT EXISTS new_col TEXT")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_foo ON foo(bar)")
|
||||
|
||||
For brand-new tables that the model also declares, the same applies — use
|
||||
`op.execute("CREATE TABLE IF NOT EXISTS ...")` or check first.
|
||||
"""
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
# Make `app` importable from this script.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.database import Base # noqa: E402
|
||||
# Import all models so they're registered on Base.metadata for autogenerate.
|
||||
from app.models import ( # noqa: E402, F401
|
||||
Photo,
|
||||
Folder,
|
||||
SourceRoot,
|
||||
Tag,
|
||||
Heap,
|
||||
HeapPhoto,
|
||||
Embedding,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
# Resolve DATABASE_URL from env. Strip async driver suffixes — Alembic
|
||||
# uses sync drivers.
|
||||
db_url = os.environ.get("DATABASE_URL") or config.get_main_option("sqlalchemy.url")
|
||||
if db_url:
|
||||
if "+asyncpg" in db_url:
|
||||
db_url = db_url.replace("+asyncpg", "+psycopg2")
|
||||
elif db_url.startswith("postgresql://"):
|
||||
db_url = db_url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||
elif "+aiosqlite" in db_url:
|
||||
db_url = db_url.replace("+aiosqlite", "")
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode (emit SQL only)."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations against a live database."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,27 +0,0 @@
|
||||
"""baseline (empty)
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-10
|
||||
|
||||
The current schema is created by SQLAlchemy `Base.metadata.create_all` in
|
||||
`app.database.init_db()` on first boot. Alembic only owns deltas from
|
||||
PR3 onward. This baseline is intentionally empty so `alembic upgrade head`
|
||||
on a fresh DB simply creates the `alembic_version` table and stamps it.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,85 +0,0 @@
|
||||
"""extend tags for vision pipeline
|
||||
|
||||
Revision ID: 0002_extend_tags
|
||||
Revises: 0001_baseline
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Add kind, source, representative_photo_id to tags table.
|
||||
Add confidence, bbox, source to photo_tags association.
|
||||
Switch uniqueness from (name) to (name, kind).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = "0002_extend_tags"
|
||||
down_revision: Union[str, None] = "0001_baseline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── tags table ────────────────────────────────────────────────────
|
||||
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS kind VARCHAR NOT NULL DEFAULT 'user'")
|
||||
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS source VARCHAR")
|
||||
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS representative_photo_id VARCHAR REFERENCES photos(id) ON DELETE SET NULL")
|
||||
|
||||
# Create index on kind for filtering
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_tags_kind ON tags(kind)")
|
||||
|
||||
# Drop old unique constraint on name (if it exists) and add (name, kind).
|
||||
# SQLAlchemy create_all may have created either — handle both cases.
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Drop the old single-column unique index/constraint if present.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'tags' AND indexname = 'ix_tags_name'
|
||||
) THEN
|
||||
DROP INDEX ix_tags_name;
|
||||
END IF;
|
||||
|
||||
-- Some SQLAlchemy versions create a unique constraint directly.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'tags' AND constraint_name = 'tags_name_key'
|
||||
) THEN
|
||||
ALTER TABLE tags DROP CONSTRAINT tags_name_key;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'uq_tags_name_kind'
|
||||
) THEN
|
||||
ALTER TABLE tags ADD CONSTRAINT uq_tags_name_kind UNIQUE (name, kind);
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# ── photo_tags table ──────────────────────────────────────────────
|
||||
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS confidence FLOAT")
|
||||
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS bbox JSONB")
|
||||
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS source VARCHAR")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# photo_tags columns
|
||||
op.drop_column("photo_tags", "source")
|
||||
op.drop_column("photo_tags", "bbox")
|
||||
op.drop_column("photo_tags", "confidence")
|
||||
|
||||
# tags: restore old unique constraint
|
||||
op.execute("ALTER TABLE tags DROP CONSTRAINT IF EXISTS uq_tags_name_kind")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tags_name ON tags(name)")
|
||||
|
||||
# tags columns
|
||||
op.drop_column("tags", "representative_photo_id")
|
||||
op.drop_column("tags", "source")
|
||||
op.drop_column("tags", "kind")
|
||||
@@ -1,52 +0,0 @@
|
||||
"""pgvector embeddings
|
||||
|
||||
Revision ID: 0003_pgvector_embeddings
|
||||
Revises: 0002_extend_tags
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Rewrite the embeddings table to use pgvector Vector(512) instead of
|
||||
LargeBinary. Add composite PK (photo_id, model), created_at, and
|
||||
HNSW index on vector column.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003_pgvector_embeddings"
|
||||
down_revision: Union[str, None] = "0002_extend_tags"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the old placeholder table and recreate with pgvector types.
|
||||
# No data to preserve — it was never populated.
|
||||
op.execute("DROP TABLE IF EXISTS embeddings")
|
||||
op.execute("""
|
||||
CREATE TABLE embeddings (
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
model VARCHAR(64) NOT NULL,
|
||||
vector vector(512),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (photo_id, model)
|
||||
)
|
||||
""")
|
||||
# HNSW index for cosine similarity search.
|
||||
# Defer creation on large backfills — drop and recreate afterward.
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_embeddings_vector_hnsw
|
||||
ON embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS embeddings")
|
||||
op.execute("""
|
||||
CREATE TABLE embeddings (
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
model VARCHAR,
|
||||
vector BYTEA,
|
||||
PRIMARY KEY (photo_id)
|
||||
)
|
||||
""")
|
||||
@@ -1,82 +0,0 @@
|
||||
"""ocr_text table and Postgres FTS
|
||||
|
||||
Revision ID: 0004_ocr_fts
|
||||
Revises: 0003_pgvector_embeddings
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Create ocr_text table for storing OCR results. Add a tsvector column
|
||||
to photos for unified full-text search (filename + user_title +
|
||||
user_notes) with a GIN index. OCR text is rolled up into a materialized
|
||||
view or joined at query time.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_ocr_fts"
|
||||
down_revision: Union[str, None] = "0003_pgvector_embeddings"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── ocr_text table ────────────────────────────────────────────────
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ocr_text (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
language VARCHAR(8) DEFAULT '',
|
||||
confidence FLOAT,
|
||||
bbox JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_ocr_text_photo_id ON ocr_text(photo_id)")
|
||||
|
||||
# ── tsvector column on photos ─────────────────────────────────────
|
||||
op.execute("ALTER TABLE photos ADD COLUMN IF NOT EXISTS search_vector tsvector")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_photos_search_vector ON photos USING GIN (search_vector)")
|
||||
|
||||
# Trigger to auto-update search_vector on INSERT/UPDATE
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION photos_search_vector_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', coalesce(NEW.filename, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(NEW.user_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(NEW.user_notes, '')), 'B');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'photos_search_vector_trigger'
|
||||
) THEN
|
||||
CREATE TRIGGER photos_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF filename, user_title, user_notes
|
||||
ON photos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION photos_search_vector_update();
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Backfill existing rows
|
||||
op.execute("""
|
||||
UPDATE photos SET search_vector =
|
||||
setweight(to_tsvector('english', coalesce(filename, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(user_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(user_notes, '')), 'B')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS photos_search_vector_trigger ON photos")
|
||||
op.execute("DROP FUNCTION IF EXISTS photos_search_vector_update()")
|
||||
op.execute("DROP INDEX IF EXISTS ix_photos_search_vector")
|
||||
op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS search_vector")
|
||||
op.execute("DROP TABLE IF EXISTS ocr_text")
|
||||
@@ -1,41 +0,0 @@
|
||||
"""face_embeddings table
|
||||
|
||||
Revision ID: 0005_face_embeddings
|
||||
Revises: 0004_ocr_fts
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Create face_embeddings table with pgvector Vector(128) for SFace
|
||||
recognition embeddings and HNSW index.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_face_embeddings"
|
||||
down_revision: Union[str, None] = "0004_ocr_fts"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS face_embeddings (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
bbox JSONB,
|
||||
vector vector(128),
|
||||
cluster_id VARCHAR REFERENCES tags(id) ON DELETE SET NULL,
|
||||
quality FLOAT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_photo_id ON face_embeddings(photo_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_cluster_id ON face_embeddings(cluster_id)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS face_embeddings")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""face_embeddings vector 128 -> 512
|
||||
|
||||
Revision ID: 0006_face_512d
|
||||
Revises: 0005_face_embeddings
|
||||
Create Date: 2026-04-10
|
||||
|
||||
Resize face_embeddings.vector from Vector(128) to Vector(512) for
|
||||
ArcFace embeddings (InsightFace). Drops existing data and HNSW index,
|
||||
recreates both.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_face_512d"
|
||||
down_revision: Union[str, None] = "0005_face_embeddings"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop index, truncate (old 128-d vectors are incompatible), resize
|
||||
op.execute("DROP INDEX IF EXISTS ix_face_embeddings_vector_hnsw")
|
||||
op.execute("DELETE FROM face_embeddings")
|
||||
op.execute("ALTER TABLE face_embeddings ALTER COLUMN vector TYPE vector(512)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_face_embeddings_vector_hnsw")
|
||||
op.execute("DELETE FROM face_embeddings")
|
||||
op.execute("ALTER TABLE face_embeddings ALTER COLUMN vector TYPE vector(128)")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
|
||||
ON face_embeddings USING hnsw (vector vector_cosine_ops)
|
||||
""")
|
||||
@@ -1,182 +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 EmbedderSettings(BaseModel):
|
||||
"""CLIP / SigLIP embedding model settings"""
|
||||
name: str = "openclip_vitb32"
|
||||
batch_size: int = 8
|
||||
|
||||
class OCRSettings(BaseModel):
|
||||
"""PaddleOCR / rapidocr settings"""
|
||||
enabled: bool = True
|
||||
languages: list[str] = ["en"]
|
||||
min_confidence: float = 0.5
|
||||
|
||||
class DetectorSettings(BaseModel):
|
||||
"""YOLOv8n object detection settings"""
|
||||
enabled: bool = True
|
||||
min_confidence: float = 0.35
|
||||
max_detections: int = 50
|
||||
|
||||
class FacesSettings(BaseModel):
|
||||
"""YuNet + SFace face detection/recognition settings"""
|
||||
enabled: bool = True
|
||||
min_face_size: int = 40
|
||||
recognition_threshold: float = 0.65
|
||||
cluster_eps: float = 0.5
|
||||
|
||||
class ClassifierSettings(BaseModel):
|
||||
"""CLIP zero-shot content classification settings"""
|
||||
enabled: bool = True
|
||||
min_confidence: float = 0.3
|
||||
|
||||
class VisionSettings(BaseModel):
|
||||
"""AI vision pipeline settings. Disabled when running on SQLite
|
||||
(pgvector is required for embedding storage)."""
|
||||
enabled: bool = True
|
||||
backend: str = "onnx" # "onnx" | "rocm" (future)
|
||||
models_dir: str = "/data/models"
|
||||
embedder: EmbedderSettings = EmbedderSettings()
|
||||
ocr: OCRSettings = OCRSettings()
|
||||
detector: DetectorSettings = DetectorSettings()
|
||||
faces: FacesSettings = FacesSettings()
|
||||
classifier: ClassifierSettings = ClassifierSettings()
|
||||
worker_concurrency: int = 2
|
||||
|
||||
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()
|
||||
vision: VisionSettings = VisionSettings()
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
# Database — Postgres + pgvector by default. The SQLite escape hatch
|
||||
# remains supported via the docker-compose.sqlite.yml override and by
|
||||
# setting DATABASE_URL=sqlite+aiosqlite:///... in .env for local dev.
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://mulita:mulita@db:5432/mulita",
|
||||
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
|
||||
|
||||
@property
|
||||
def vision(self) -> VisionSettings:
|
||||
return self.config.vision
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
@@ -1,168 +0,0 @@
|
||||
"""
|
||||
Database configuration and session management.
|
||||
|
||||
Schema management strategy
|
||||
--------------------------
|
||||
Postgres (default): Alembic owns schema deltas. `alembic upgrade head` is
|
||||
run before the app starts (in the container CMD). `init_db()` calls
|
||||
`create_all` afterward as the source of truth for fresh installs — it is
|
||||
idempotent for existing tables and creates any tables defined on
|
||||
`Base.metadata` that don't yet exist. Future Alembic migrations should be
|
||||
written defensively (`IF NOT EXISTS` etc.) so they remain safe to run on a
|
||||
fresh DB where `create_all` has already laid down the same objects.
|
||||
|
||||
SQLite (escape hatch via docker-compose.sqlite.yml): no Alembic. The
|
||||
historical inline ALTER TABLE block stays in place so existing dev
|
||||
installs keep upgrading.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_is_sqlite = settings.database_url.startswith("sqlite")
|
||||
_is_postgres = settings.database_url.startswith("postgresql")
|
||||
|
||||
if _is_sqlite:
|
||||
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
connect_args={
|
||||
"check_same_thread": False,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
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
|
||||
|
||||
# Postgres: ensure pgvector is available before create_all touches
|
||||
# any Vector columns (added in later PRs but the extension is cheap
|
||||
# and idempotent to create now).
|
||||
if _is_postgres:
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
|
||||
# Create all tables. Note: create_all only creates *missing* tables —
|
||||
# it does NOT add new columns to existing tables when the model gains
|
||||
# them. On Postgres, Alembic handles deltas; on SQLite, the inline
|
||||
# ALTER block below is the legacy fallback.
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
gps_columns_added = False
|
||||
|
||||
if _is_sqlite:
|
||||
# WAL mode for better concurrency.
|
||||
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"))
|
||||
|
||||
# ── Idempotent column adds (SQLite only) ─────────────────────
|
||||
# SQLite supports ADD COLUMN but not "IF NOT EXISTS" for
|
||||
# columns, so introspect via PRAGMA first. Each entry is
|
||||
# (column_name, ALTER statement). Add new columns at the
|
||||
# bottom. On Postgres these live in Alembic migrations.
|
||||
existing_cols = {
|
||||
row[1]
|
||||
for row in (
|
||||
await conn.execute(text("PRAGMA table_info(photos)"))
|
||||
).fetchall()
|
||||
}
|
||||
pending_alters: list[tuple[str, str]] = [
|
||||
("phash", "ALTER TABLE photos ADD COLUMN phash VARCHAR(16)"),
|
||||
(
|
||||
"duplicate_group_id",
|
||||
"ALTER TABLE photos ADD COLUMN duplicate_group_id VARCHAR",
|
||||
),
|
||||
("latitude", "ALTER TABLE photos ADD COLUMN latitude REAL"),
|
||||
("longitude", "ALTER TABLE photos ADD COLUMN longitude REAL"),
|
||||
]
|
||||
for col_name, alter_sql in pending_alters:
|
||||
if col_name not in existing_cols:
|
||||
logger.info(f"Adding photos.{col_name} column")
|
||||
await conn.execute(text(alter_sql))
|
||||
if col_name in ("latitude", "longitude"):
|
||||
gps_columns_added = True
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_photos_phash ON photos(phash)")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_photos_duplicate_group_id "
|
||||
"ON photos(duplicate_group_id)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_photos_lat_lon "
|
||||
"ON photos(latitude, longitude)"
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# If we just introduced the GPS columns on an existing SQLite
|
||||
# install, kick off a one-shot backfill so the Map view is
|
||||
# populated without a manual full re-scan. Postgres installs are
|
||||
# always fresh (no SQLite→PG migration path), so this code path
|
||||
# is SQLite-only.
|
||||
if _is_sqlite and gps_columns_added:
|
||||
try:
|
||||
from app.tasks.scan import backfill_gps
|
||||
backfill_gps.delay()
|
||||
logger.info("Queued one-shot backfill_gps task after column add")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue backfill_gps task: {e}")
|
||||
|
||||
async def create_fts_table():
|
||||
"""Create Full-Text Search table for SQLite. On Postgres this is
|
||||
replaced by a tsvector column on the photos table (added in PR5)."""
|
||||
if _is_sqlite:
|
||||
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,108 +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, search
|
||||
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.include_router(search.router, prefix="/api/v1/photos/search", tags=["search"])
|
||||
|
||||
@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,23 +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
|
||||
from app.models.ocr_text import OCRText
|
||||
from app.models.face_embedding import FaceEmbedding
|
||||
|
||||
__all__ = [
|
||||
'Photo',
|
||||
'Folder',
|
||||
'SourceRoot',
|
||||
'Tag',
|
||||
'PhotoTag',
|
||||
'Heap',
|
||||
'HeapPhoto',
|
||||
'Embedding',
|
||||
'OCRText',
|
||||
'FaceEmbedding',
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Embedding model — stores CLIP/SigLIP image embeddings via pgvector.
|
||||
|
||||
Composite PK (photo_id, model) allows re-embedding with newer models
|
||||
without clobbering old vectors.
|
||||
"""
|
||||
from sqlalchemy import Column, String, ForeignKey, DateTime, func
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Embedding(Base):
|
||||
__tablename__ = 'embeddings'
|
||||
|
||||
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
|
||||
model = Column(String(64), primary_key=True) # e.g. 'openclip_vitb32'
|
||||
vector = Column(Vector(512)) # OpenCLIP ViT-B/32 → 512-d
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,24 +0,0 @@
|
||||
"""
|
||||
Face embedding model — stores per-face detection + recognition vectors.
|
||||
|
||||
cluster_id FKs to tags.id where kind='face_cluster'. Null means
|
||||
unclustered (will be assigned by recluster_faces).
|
||||
"""
|
||||
from sqlalchemy import Column, String, Float, ForeignKey, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from pgvector.sqlalchemy import Vector
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FaceEmbedding(Base):
|
||||
__tablename__ = 'face_embeddings'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
|
||||
vector = Column(Vector(512)) # ArcFace → 512-d
|
||||
cluster_id = Column(String, ForeignKey('tags.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
quality = Column(Float)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -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,20 +0,0 @@
|
||||
"""
|
||||
OCR text model — stores text regions extracted from photos via rapidocr.
|
||||
"""
|
||||
from sqlalchemy import Column, String, Float, ForeignKey, Text, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class OCRText(Base):
|
||||
__tablename__ = 'ocr_text'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
language = Column(String(8), default='')
|
||||
confidence = Column(Float)
|
||||
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Photo model definition
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, Float, 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
|
||||
|
||||
# GPS coordinates extracted from EXIF, in signed decimal degrees
|
||||
# (S latitude / W longitude are negative). Stored as first-class columns
|
||||
# so the Map view and any future location filters can query/index them
|
||||
# without parsing exif_json on every request.
|
||||
latitude = Column(Float)
|
||||
longitude = Column(Float)
|
||||
|
||||
# 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.
|
||||
#
|
||||
# - file_hash (above): SHA-256 of the raw bytes. Catches byte-identical
|
||||
# copies but not visually-identical re-encodes / resizes / screenshots.
|
||||
# - phash: 16-char hex of a 64-bit perceptual hash, computed by the
|
||||
# thumbs worker from the decoded original frame. Robust to resize and
|
||||
# re-compression — this is what actually identifies "the same photo
|
||||
# saved twice with different JPEG quality".
|
||||
# - duplicate_group_id: shared by every photo in the same duplicate
|
||||
# cluster. Maintained by app.services.duplicates.regroup_duplicates,
|
||||
# not on individual writes — recomputed in batches after scans / on
|
||||
# demand from the Settings panel.
|
||||
# - is_duplicate: derived boolean (group_id IS NOT NULL). Kept as a real
|
||||
# column so the existing PhotoThumbnail badge and /library/stats
|
||||
# duplicates count don't have to change.
|
||||
is_duplicate = Column(Boolean, default=False)
|
||||
phash = Column(String(16), index=True)
|
||||
duplicate_group_id = Column(String, index=True)
|
||||
|
||||
# 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'),
|
||||
Index('ix_photos_lat_lon', 'latitude', 'longitude'),
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
Tag model definitions.
|
||||
|
||||
Tags are unified across user-created tags, ML-detected objects, scene
|
||||
labels, and face clusters via the `kind` column. The `photo_tags`
|
||||
association carries per-photo ML metadata (confidence, bounding box,
|
||||
source model).
|
||||
"""
|
||||
from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
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),
|
||||
# ML metadata — null for user-applied tags
|
||||
Column('confidence', Float, nullable=True),
|
||||
Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1
|
||||
Column('source', String, nullable=True), # e.g. "vision:yolov8n", "vision:sface"
|
||||
Index('ix_photo_tags_photo_id', 'photo_id'),
|
||||
Index('ix_photo_tags_tag_id', 'tag_id'),
|
||||
)
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = 'tags'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('name', 'kind', name='uq_tags_name_kind'),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False, index=True)
|
||||
color = Column(String) # Hex color code for UI display
|
||||
|
||||
# Tag classification
|
||||
kind = Column(String, nullable=False, default='user', index=True)
|
||||
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
|
||||
|
||||
# Which model produced this tag (null for user-created)
|
||||
source = Column(String, nullable=True)
|
||||
# e.g. "vision:yolov8n", "vision:sface", null
|
||||
|
||||
# For face clusters: the photo used as the cluster representative thumbnail
|
||||
representative_photo_id = Column(
|
||||
String, ForeignKey('photos.id', ondelete='SET NULL'), nullable=True
|
||||
)
|
||||
|
||||
# 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,606 +0,0 @@
|
||||
"""
|
||||
Library API router for stats, scanning, and maintenance.
|
||||
|
||||
The /maintenance/* endpoints are surfaced through the frontend Settings
|
||||
panel. They're intentionally idempotent and operate by re-queueing the
|
||||
existing Celery tasks rather than doing any heavy lifting in the
|
||||
request thread.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Media types we accept in the regenerate-thumbnails request body. Mirrors
|
||||
# the values produced by `app.tasks.scan.get_media_type`.
|
||||
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
|
||||
|
||||
@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
|
||||
- colored: non-discarded with a color_label set (matches the
|
||||
Colors grouped view's labeled buckets)
|
||||
- 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
|
||||
|
||||
colored_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.color_label.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
with_gps_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.latitude.is_not(None)
|
||||
)
|
||||
)
|
||||
).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,
|
||||
"colored": colored_count,
|
||||
"with_gps": with_gps_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.post("/backfill-gps")
|
||||
async def trigger_backfill_gps():
|
||||
"""Re-run EXIF metadata extraction on every photo that's still missing
|
||||
GPS coordinates. Useful after fixing the EXIF parser, or any time the
|
||||
Map view looks emptier than expected. Returns immediately — work runs
|
||||
on the Celery worker."""
|
||||
from app.tasks.scan import backfill_gps
|
||||
|
||||
backfill_gps.delay()
|
||||
return {"status": "success", "message": "GPS backfill queued"}
|
||||
|
||||
@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 []
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maintenance endpoints — surfaced via the Settings panel.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RegenerateThumbnailsRequest(BaseModel):
|
||||
"""Optional filters narrowing which photos get re-queued. With both
|
||||
fields omitted the request resets every photo in the library."""
|
||||
media_types: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Restrict to these media_type values (photo/raw/heic/video).",
|
||||
)
|
||||
only_failed: bool = Field(
|
||||
default=False,
|
||||
description="If true, only re-queue photos whose processing_status is 'failed'.",
|
||||
)
|
||||
only_pending: bool = Field(
|
||||
default=False,
|
||||
description="If true, only (re-)queue photos whose processing_status is 'pending'. "
|
||||
"Useful for kicking rows that were created by a scan but never had "
|
||||
"their thumbnail task picked up.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/maintenance/thumbnail-stats")
|
||||
async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Counts of photos by processing_status, plus a media-type breakdown
|
||||
so the Settings panel can show the user what's outstanding."""
|
||||
status_rows = (
|
||||
await db.execute(
|
||||
select(Photo.processing_status, func.count(Photo.id)).group_by(
|
||||
Photo.processing_status
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
media_rows = (
|
||||
await db.execute(
|
||||
select(Photo.media_type, func.count(Photo.id)).group_by(Photo.media_type)
|
||||
)
|
||||
).all()
|
||||
|
||||
by_status = {status or 'unknown': count for status, count in status_rows}
|
||||
by_media_type = {media or 'unknown': count for media, count in media_rows}
|
||||
total = sum(by_status.values())
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": by_status.get('pending', 0),
|
||||
"processing": by_status.get('processing', 0),
|
||||
"completed": by_status.get('completed', 0),
|
||||
"failed": by_status.get('failed', 0),
|
||||
"by_media_type": by_media_type,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance/regenerate-thumbnails")
|
||||
async def regenerate_thumbnails(
|
||||
body: RegenerateThumbnailsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reset matching photos' on-disk thumbnail directories and re-queue
|
||||
Celery thumbnail generation. Used by the Settings panel for the
|
||||
'regenerate video thumbnails' / 'regenerate failed' buttons.
|
||||
|
||||
Files on disk are removed under /data/thumbs/<photo_id>/ so the next
|
||||
request to /photos/{id}/thumb/{size} actually re-generates instead of
|
||||
serving the stale placeholder.
|
||||
"""
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
|
||||
# Validate media_types early so a typo can't silently match nothing.
|
||||
media_types = body.media_types
|
||||
if media_types is not None:
|
||||
invalid = [m for m in media_types if m not in _VALID_MEDIA_TYPES]
|
||||
if invalid:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Invalid media_types: {invalid}. "
|
||||
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
|
||||
}
|
||||
|
||||
query = select(Photo)
|
||||
if media_types:
|
||||
query = query.where(Photo.media_type.in_(media_types))
|
||||
if body.only_failed:
|
||||
query = query.where(Photo.processing_status == 'failed')
|
||||
if body.only_pending:
|
||||
query = query.where(Photo.processing_status == 'pending')
|
||||
|
||||
photos = (await db.execute(query)).scalars().all()
|
||||
|
||||
cleared_dirs = 0
|
||||
file_errors = 0
|
||||
for photo in photos:
|
||||
thumb_dir = f"/data/thumbs/{photo.id}"
|
||||
if os.path.isdir(thumb_dir):
|
||||
try:
|
||||
shutil.rmtree(thumb_dir)
|
||||
cleared_dirs += 1
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.warning(f"Could not clear thumb dir {thumb_dir}: {e}")
|
||||
photo.processing_status = 'pending'
|
||||
photo.processing_error = None
|
||||
photo.thumb_small = None
|
||||
photo.thumb_medium = None
|
||||
photo.thumb_large = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Queue celery tasks AFTER the commit so the worker sees the reset
|
||||
# state when it picks the job up.
|
||||
queued = 0
|
||||
for photo in photos:
|
||||
try:
|
||||
generate_thumbnails.delay(photo.id)
|
||||
queued += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue thumbnail job for {photo.id}: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"matched": len(photos),
|
||||
"queued": queued,
|
||||
"cleared_dirs": cleared_dirs,
|
||||
"file_errors": file_errors,
|
||||
"filters": {
|
||||
"media_types": media_types,
|
||||
"only_failed": body.only_failed,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/worker-status")
|
||||
async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Diagnostics for the Celery worker fleet + recent task failures.
|
||||
|
||||
Surfaced in the Settings panel so the user can spot a stuck queue or
|
||||
a worker that's gone away without tailing container logs. Returns:
|
||||
|
||||
- workers: list of {name, status, active, concurrency, queues}
|
||||
derived from celery_app.control.inspect(). `status` is 'online'
|
||||
when ping succeeds, 'unreachable' otherwise. Empty list means no
|
||||
workers are responding at all (broker down, container crashed,
|
||||
wrong queue routing, etc.).
|
||||
- queues: per-queue depth read from Redis (LLEN of each queue key
|
||||
used by celery.kombu). Mirrors what tasks are waiting to be
|
||||
picked up.
|
||||
- failures: aggregate count of photos with processing_status='failed'
|
||||
plus the most recent N error messages so the user can see *why*
|
||||
things failed without opening the DB.
|
||||
- broker_ok: bool — could we even reach Redis?
|
||||
"""
|
||||
from app.tasks.celery import celery_app
|
||||
from app.config import settings
|
||||
import redis as _redis
|
||||
|
||||
# ----- Celery inspect (workers + active tasks) -------------------------
|
||||
# Each inspect.* call is a separate broadcast-and-wait with its own
|
||||
# timeout, so running them serially multiplies the wait. Fan them out
|
||||
# to threads and gather, collapsing 6 × timeout into ~1 × timeout.
|
||||
# Timeout dropped to 0.5s — a responsive worker answers within a few
|
||||
# ms; anything past that is effectively "not responding" for the
|
||||
# purposes of a settings dashboard.
|
||||
import asyncio
|
||||
workers: list[dict] = []
|
||||
inspect_error: Optional[str] = None
|
||||
try:
|
||||
inspect = celery_app.control.inspect(timeout=0.5)
|
||||
ping, active, reserved, scheduled, stats, active_queues = await asyncio.gather(
|
||||
asyncio.to_thread(inspect.ping),
|
||||
asyncio.to_thread(inspect.active),
|
||||
asyncio.to_thread(inspect.reserved),
|
||||
asyncio.to_thread(inspect.scheduled),
|
||||
asyncio.to_thread(inspect.stats),
|
||||
asyncio.to_thread(inspect.active_queues),
|
||||
)
|
||||
ping = ping or {}
|
||||
active = active or {}
|
||||
reserved = reserved or {}
|
||||
scheduled = scheduled or {}
|
||||
stats = stats or {}
|
||||
active_queues = active_queues or {}
|
||||
|
||||
worker_names = set(ping) | set(active) | set(stats)
|
||||
for name in sorted(worker_names):
|
||||
wstats = stats.get(name) or {}
|
||||
pool = wstats.get('pool') or {}
|
||||
workers.append({
|
||||
"name": name,
|
||||
"status": "online" if name in ping else "unreachable",
|
||||
"active": len(active.get(name, []) or []),
|
||||
"reserved": len(reserved.get(name, []) or []),
|
||||
"scheduled": len(scheduled.get(name, []) or []),
|
||||
"concurrency": pool.get('max-concurrency'),
|
||||
"processed": (wstats.get('total') or {}),
|
||||
"queues": [q.get('name') for q in (active_queues.get(name) or [])],
|
||||
"active_tasks": [
|
||||
{
|
||||
"id": t.get('id'),
|
||||
"name": t.get('name'),
|
||||
"args": t.get('args'),
|
||||
"time_start": t.get('time_start'),
|
||||
}
|
||||
for t in (active.get(name) or [])[:10]
|
||||
],
|
||||
})
|
||||
except Exception as e:
|
||||
inspect_error = str(e)
|
||||
logger.warning(f"Celery inspect failed: {e}")
|
||||
|
||||
# ----- Broker / queue depth --------------------------------------------
|
||||
broker_ok = False
|
||||
queue_depths: dict[str, int] = {}
|
||||
broker_error: Optional[str] = None
|
||||
try:
|
||||
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
|
||||
r.ping()
|
||||
broker_ok = True
|
||||
for q in ('default', 'high', 'low'):
|
||||
try:
|
||||
queue_depths[q] = int(r.llen(q) or 0)
|
||||
except Exception:
|
||||
queue_depths[q] = 0
|
||||
except Exception as e:
|
||||
broker_error = str(e)
|
||||
logger.warning(f"Redis broker unreachable: {e}")
|
||||
|
||||
# ----- Recent task failures from the photos table ----------------------
|
||||
failed_total = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.processing_status == 'failed')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
recent_failed_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.media_type,
|
||||
Photo.processing_error,
|
||||
Photo.updated_at,
|
||||
)
|
||||
.where(Photo.processing_status == 'failed')
|
||||
.order_by(Photo.updated_at.desc().nullslast())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
|
||||
recent_failures = [
|
||||
{
|
||||
"photo_id": row[0],
|
||||
"filename": row[1],
|
||||
"media_type": row[2],
|
||||
"error": (row[3] or '')[:500],
|
||||
"updated_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
for row in recent_failed_rows
|
||||
]
|
||||
|
||||
# ----- Most recent scan errors (Redis list) ----------------------------
|
||||
scan_errors: list[str] = []
|
||||
try:
|
||||
if broker_ok:
|
||||
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
|
||||
raw = r.lrange('scan:errors', 0, 19) or []
|
||||
scan_errors = [e.decode(errors='replace') for e in raw]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read scan:errors: {e}")
|
||||
|
||||
return {
|
||||
"broker_ok": broker_ok,
|
||||
"broker_error": broker_error,
|
||||
"inspect_error": inspect_error,
|
||||
"workers": workers,
|
||||
"worker_count": len(workers),
|
||||
"queues": queue_depths,
|
||||
"failures": {
|
||||
"total": failed_total,
|
||||
"recent": recent_failures,
|
||||
},
|
||||
"scan_errors": scan_errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/missing-stats")
|
||||
async def get_missing_stats():
|
||||
"""Count photos whose files no longer exist on disk under a mounted
|
||||
source root. Surfaced in Settings so the user can see a number before
|
||||
pulling the trigger on prune-missing. Cheap enough to call freely."""
|
||||
from app.services.cleanup import prune_missing_photos
|
||||
return await prune_missing_photos(dry_run=True)
|
||||
|
||||
|
||||
@router.post("/maintenance/prune-missing")
|
||||
async def run_prune_missing():
|
||||
"""Actually delete the orphaned photo rows reported by /missing-stats.
|
||||
Common cause: PHOTO_DIRS in .env was repointed at a different library
|
||||
leaving every old row dangling. Skips any photo whose source root
|
||||
isn't currently mounted (almost always means an unmounted drive)."""
|
||||
from app.services.cleanup import prune_missing_photos
|
||||
try:
|
||||
return {"status": "success", **(await prune_missing_photos(dry_run=False))}
|
||||
except Exception as e:
|
||||
logger.error(f"Prune missing failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/maintenance/cleanup")
|
||||
async def run_data_integrity_cleanup():
|
||||
"""Re-run the source-roots / folders / photos data-integrity cleanup
|
||||
that normally only runs on backend startup. Idempotent."""
|
||||
from app.services.cleanup import cleanup_data_integrity
|
||||
|
||||
try:
|
||||
await cleanup_data_integrity()
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
logger.error(f"Manual cleanup failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Duplicate detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/duplicates/groups")
|
||||
async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
||||
"""Return every duplicate group with its members.
|
||||
|
||||
Drives the frontend grouped grid view in the Duplicates section. One
|
||||
SQL query, bucketed in Python — no N+1, no per-member fetch. Groups
|
||||
are sorted by member_count DESC then earliest taken_at DESC so the
|
||||
biggest / most recent clusters bubble to the top.
|
||||
|
||||
Each group also carries a `reason` field:
|
||||
* "exact" — every member shares the same SHA-256 (true byte
|
||||
duplicates that the perceptual hash trivially caught)
|
||||
* "similar" — members differ at the byte level but match perceptually
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.taken_at,
|
||||
Photo.file_size,
|
||||
Photo.width,
|
||||
Photo.height,
|
||||
Photo.thumb_small,
|
||||
Photo.file_hash,
|
||||
Photo.folder_id,
|
||||
Photo.media_type,
|
||||
Photo.duplicate_group_id,
|
||||
)
|
||||
.where(Photo.duplicate_group_id.is_not(None))
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
.order_by(Photo.duplicate_group_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
# Bucket members by group_id.
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
member = {
|
||||
"id": row[0],
|
||||
"filename": row[1],
|
||||
"taken_at": row[2].isoformat() if row[2] else None,
|
||||
"file_size": row[3],
|
||||
"width": row[4],
|
||||
"height": row[5],
|
||||
"thumb_small": row[6],
|
||||
"file_hash": row[7],
|
||||
"folder_id": row[8],
|
||||
"media_type": row[9],
|
||||
}
|
||||
groups.setdefault(row[10], []).append(member)
|
||||
|
||||
def earliest(g: list[dict]) -> str:
|
||||
# Used as a secondary sort key. Photos with no taken_at sort last
|
||||
# by returning a far-future sentinel.
|
||||
taken = [m["taken_at"] for m in g if m["taken_at"]]
|
||||
return min(taken) if taken else "9999"
|
||||
|
||||
out = []
|
||||
for group_id, members in groups.items():
|
||||
if len(members) < 2:
|
||||
# Defensive: a regroup race could leave a singleton briefly.
|
||||
# Skip it so the UI never shows a "group of 1".
|
||||
continue
|
||||
# exact iff every member shares the same non-null file_hash
|
||||
# (true byte-identical copies that pHash also caught). Anything
|
||||
# else — different hashes, missing hashes — counts as "similar".
|
||||
all_hashes = [m["file_hash"] for m in members]
|
||||
reason = (
|
||||
"exact"
|
||||
if len(set(all_hashes)) == 1 and all_hashes[0] is not None
|
||||
else "similar"
|
||||
)
|
||||
out.append({
|
||||
"group_id": group_id,
|
||||
"member_count": len(members),
|
||||
"reason": reason,
|
||||
"members": members,
|
||||
})
|
||||
|
||||
out.sort(key=lambda g: (-g["member_count"], earliest(g["members"])))
|
||||
return {
|
||||
"groups": out,
|
||||
"total_groups": len(out),
|
||||
"total_members": sum(g["member_count"] for g in out),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance/regroup-duplicates")
|
||||
async def trigger_regroup_duplicates():
|
||||
"""Recompute duplicate groups from current perceptual hashes.
|
||||
|
||||
Fires the celery `regroup_duplicates` task which walks every photo's
|
||||
phash, clusters by Hamming distance, and rewrites duplicate_group_id /
|
||||
is_duplicate columns. Idempotent."""
|
||||
from app.tasks.thumbs import regroup_duplicates_task
|
||||
try:
|
||||
regroup_duplicates_task.delay()
|
||||
return {"status": "queued"}
|
||||
except Exception as e:
|
||||
logger.error(f"Regroup queue failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/maintenance/backfill-phashes")
|
||||
async def trigger_backfill_phashes():
|
||||
"""Compute perceptual hashes for every photo currently missing one.
|
||||
|
||||
One-shot recovery path for libraries that existed before the phash
|
||||
column was added — the thumbs worker computes phash for everything
|
||||
new, but old rows need a backfill pass."""
|
||||
from app.tasks.thumbs import backfill_phashes
|
||||
try:
|
||||
backfill_phashes.delay()
|
||||
return {"status": "queued"}
|
||||
except Exception as e:
|
||||
logger.error(f"Backfill queue failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -1,966 +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("/map")
|
||||
async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
|
||||
"""Lightweight listing of every non-discarded photo that has GPS
|
||||
coordinates, used by the Map view. Intentionally returns a flat list
|
||||
(no pagination) with only the fields the map renderer needs, so even
|
||||
large libraries serialize to a few MB at most. Declared *before*
|
||||
``/{photo_id}`` so the literal path wins the FastAPI route match."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
Photo.latitude,
|
||||
Photo.longitude,
|
||||
Photo.taken_at,
|
||||
).where(
|
||||
Photo.is_discarded.is_(False),
|
||||
Photo.latitude.is_not(None),
|
||||
Photo.longitude.is_not(None),
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"latitude": row.latitude,
|
||||
"longitude": row.longitude,
|
||||
"taken_at": row.taken_at.isoformat() if row.taken_at else None,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
|
||||
@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):
|
||||
# Queue background generation (handles RAW/HEIC/video properly)
|
||||
from app.tasks.thumbs import generate_thumbnails
|
||||
generate_thumbnails.delay(photo_id)
|
||||
|
||||
# Best-effort inline fallback for standard images so the first
|
||||
# request doesn't have to wait for the worker. RAW/HEIC/video
|
||||
# and missing source files fall through to a clean 404 below.
|
||||
if photo.filepath and 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 HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Inline thumbnail fallback failed for {photo_id} ({size}); "
|
||||
f"waiting on worker: {e}"
|
||||
)
|
||||
|
||||
# If the inline fallback didn't (or couldn't) produce the file,
|
||||
# tell the client to retry instead of crashing in FileResponse/nginx.
|
||||
if not os.path.exists(thumb_path):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Thumbnail not ready",
|
||||
headers={"Retry-After": "2"},
|
||||
)
|
||||
|
||||
# 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,74 +0,0 @@
|
||||
"""
|
||||
Search API router — unified hybrid search endpoint.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.services.search import hybrid_search
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
q: Optional[str] = None
|
||||
filters: Optional[dict] = None
|
||||
limit: int = 50
|
||||
offset: int = 0
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
|
||||
by default — the user never picks a mode.
|
||||
|
||||
Filters:
|
||||
- tag_ids: list of tag IDs (any kind: user, object, face_cluster)
|
||||
- date_from / date_to: ISO date strings
|
||||
"""
|
||||
filters = body.filters or {}
|
||||
|
||||
results = await hybrid_search(
|
||||
db=db,
|
||||
q=body.q,
|
||||
tag_ids=filters.get("tag_ids"),
|
||||
date_from=filters.get("date_from"),
|
||||
date_to=filters.get("date_to"),
|
||||
limit=body.limit,
|
||||
offset=body.offset,
|
||||
)
|
||||
|
||||
if not results:
|
||||
return {"results": [], "total": 0}
|
||||
|
||||
# Hydrate with photo data
|
||||
photo_ids = [r["photo_id"] for r in results]
|
||||
stmt = select(Photo).where(Photo.id.in_(photo_ids))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
photo_map = {p.id: p for p in rows}
|
||||
|
||||
hydrated = []
|
||||
for r in results:
|
||||
photo = photo_map.get(r["photo_id"])
|
||||
if not photo:
|
||||
continue
|
||||
hydrated.append({
|
||||
"id": photo.id,
|
||||
"filename": photo.filename,
|
||||
"filepath": photo.filepath,
|
||||
"media_type": photo.media_type,
|
||||
"width": photo.width,
|
||||
"height": photo.height,
|
||||
"taken_at": photo.taken_at.isoformat() if photo.taken_at else None,
|
||||
"rating": photo.rating,
|
||||
"color_label": photo.color_label,
|
||||
"thumb_small": photo.thumb_small,
|
||||
"thumb_medium": photo.thumb_medium,
|
||||
"score": r["score"],
|
||||
})
|
||||
|
||||
return {"results": hydrated, "total": len(hydrated)}
|
||||
@@ -1,186 +0,0 @@
|
||||
"""
|
||||
Tags API router.
|
||||
|
||||
Unified across user tags, ML-detected objects, and face clusters via
|
||||
the `kind` query parameter. Default behaviour (no kind filter) returns
|
||||
all tags — the frontend's "Hide auto-generated tags" toggle filters
|
||||
client-side or passes `kind=user`.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update
|
||||
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
|
||||
kind: str = "user"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TagMerge(BaseModel):
|
||||
target_id: str # tag to merge INTO
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_tags(
|
||||
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all tags with their photo counts, optionally filtered by kind."""
|
||||
count_subq = (
|
||||
select(
|
||||
photo_tags.c.tag_id,
|
||||
func.count(photo_tags.c.photo_id).label("photo_count"),
|
||||
func.min(photo_tags.c.photo_id).label("first_photo_id"),
|
||||
)
|
||||
.group_by(photo_tags.c.tag_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
)
|
||||
if kind:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
stmt = stmt.order_by(Tag.name.asc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"kind": tag.kind,
|
||||
"source": tag.source,
|
||||
"representative_photo_id": tag.representative_photo_id or first_photo_id,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for tag, count, first_photo_id in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||
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, Tag.kind == body.kind)
|
||||
)
|
||||
found = existing.scalar_one_or_none()
|
||||
if found:
|
||||
return {
|
||||
"id": found.id, "name": found.name, "color": found.color,
|
||||
"kind": found.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return {
|
||||
"id": tag.id, "name": tag.name, "color": tag.color,
|
||||
"kind": tag.kind, "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 (works for any kind — user, object, face_cluster)."""
|
||||
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, "kind": tag.kind}
|
||||
|
||||
|
||||
@router.post("/{tag_id}/merge")
|
||||
async def merge_tag(
|
||||
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Merge tag_id INTO target_id. All photo associations from the source
|
||||
tag are moved to the target, then the source tag is deleted.
|
||||
|
||||
Useful for merging auto-detected face clusters (e.g. "Person 3" → "Alice")
|
||||
or merging duplicate object labels."""
|
||||
if tag_id == body.target_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
|
||||
|
||||
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source tag not found")
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target tag not found")
|
||||
|
||||
# Move photo associations: update tag_id from source → target.
|
||||
# Skip any that would violate the PK (photo already tagged with target).
|
||||
existing_target_photos = select(photo_tags.c.photo_id).where(
|
||||
photo_tags.c.tag_id == body.target_id
|
||||
)
|
||||
await db.execute(
|
||||
update(photo_tags)
|
||||
.where(
|
||||
photo_tags.c.tag_id == tag_id,
|
||||
photo_tags.c.photo_id.notin_(existing_target_photos),
|
||||
)
|
||||
.values(tag_id=body.target_id)
|
||||
)
|
||||
# Delete remaining source associations (duplicates that couldn't move)
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(
|
||||
sa_delete(photo_tags).where(photo_tags.c.tag_id == tag_id)
|
||||
)
|
||||
|
||||
# Delete source tag
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
return {"merged_into": target.id, "target_name": target.name}
|
||||
|
||||
|
||||
@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,71 +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
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = 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,275 +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 find_missing(
|
||||
session: AsyncSession,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Walk every non-discarded photo + every folder and check whether
|
||||
they still resolve on disk. Returns
|
||||
(deletable_photo_ids, deletable_folder_ids, skipped_photo_ids).
|
||||
|
||||
Skipped rows are photos/folders whose owning source_root path itself
|
||||
doesn't resolve — that's almost always an unmounted drive, and
|
||||
silently deleting those rows would be data loss. The caller can
|
||||
surface the skip count separately so the user knows the cleanup
|
||||
wasn't a no-op by accident.
|
||||
"""
|
||||
sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
|
||||
sr_mounted: dict[str, bool] = {sr.id: os.path.isdir(sr.path) for sr in sr_rows}
|
||||
|
||||
photos = (await session.execute(
|
||||
select(Photo.id, Photo.filepath, Photo.folder_id)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
)).all()
|
||||
|
||||
folders = (await session.execute(
|
||||
select(Folder.id, Folder.path, Folder.source_root_id)
|
||||
)).all()
|
||||
folder_to_sr = {fid: srid for fid, _path, srid in folders}
|
||||
|
||||
deletable_photos: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for pid, fp, folder_id in photos:
|
||||
sr_id = folder_to_sr.get(folder_id)
|
||||
if sr_id is None or not sr_mounted.get(sr_id, False):
|
||||
skipped.append(pid)
|
||||
continue
|
||||
if not os.path.exists(fp):
|
||||
deletable_photos.append(pid)
|
||||
|
||||
deletable_folders: list[str] = []
|
||||
for fid, fpath, sr_id in folders:
|
||||
if sr_id is None or not sr_mounted.get(sr_id, False):
|
||||
continue
|
||||
if not os.path.isdir(fpath):
|
||||
deletable_folders.append(fid)
|
||||
|
||||
return deletable_photos, deletable_folders, skipped
|
||||
|
||||
|
||||
async def prune_missing_photos(dry_run: bool = True) -> dict:
|
||||
"""Delete photo + folder rows whose paths are no longer on disk *and*
|
||||
whose source root is currently mounted. Common cause: PHOTO_DIRS in
|
||||
.env was repointed at a different library, leaving every old row
|
||||
orphaned.
|
||||
|
||||
Set dry_run=False to actually delete. The default is intentionally
|
||||
safe so the matching count can be surfaced in the UI before the
|
||||
user commits to it.
|
||||
|
||||
Function name kept for backwards compatibility — it now also prunes
|
||||
folders, not just photos.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
deletable_photos, deletable_folders, skipped = await find_missing(session)
|
||||
if not dry_run:
|
||||
CHUNK = 500
|
||||
# Photos first (folders may FK from them via folder_id).
|
||||
for i in range(0, len(deletable_photos), CHUNK):
|
||||
await session.execute(
|
||||
delete(Photo).where(
|
||||
Photo.id.in_(deletable_photos[i:i + CHUNK])
|
||||
)
|
||||
)
|
||||
# Then drop folders that ALSO no longer have any photos
|
||||
# pointing at them. We re-check after the photo delete so
|
||||
# we don't strand a folder that legitimately exists on
|
||||
# disk but happened to match the orphan list.
|
||||
if deletable_folders:
|
||||
for i in range(0, len(deletable_folders), CHUNK):
|
||||
chunk = deletable_folders[i:i + CHUNK]
|
||||
# Only delete folders that now have zero photos
|
||||
# left attached (defensive — should always be 0
|
||||
# if the path is gone, but a concurrent scan
|
||||
# could re-create rows).
|
||||
still_used = (await session.execute(
|
||||
select(Photo.folder_id)
|
||||
.where(Photo.folder_id.in_(chunk))
|
||||
.distinct()
|
||||
)).scalars().all()
|
||||
safe = [f for f in chunk if f not in set(still_used)]
|
||||
if safe:
|
||||
await session.execute(
|
||||
delete(Folder).where(Folder.id.in_(safe))
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Pruned {len(deletable_photos)} photo rows + "
|
||||
f"{len(deletable_folders)} folder rows"
|
||||
)
|
||||
key_p = "would_delete" if dry_run else "deleted"
|
||||
key_f = "would_delete_folders" if dry_run else "deleted_folders"
|
||||
return {
|
||||
key_p: len(deletable_photos),
|
||||
key_f: len(deletable_folders),
|
||||
"skipped_unmounted": len(skipped),
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"prune_missing_photos failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
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,227 +0,0 @@
|
||||
"""
|
||||
Duplicate detection: group photos by perceptual-hash similarity.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
Each photo carries a 16-char hex perceptual hash (`Photo.phash`) computed
|
||||
by the thumbnail worker from the original-resolution decoded frame
|
||||
(`app.tasks.thumbs._generate_thumbnails_async`). pHash is robust to
|
||||
resize / re-encoding / mild edits, so two photos that are visually the
|
||||
"same shot" land at small Hamming distance even when their bytes are
|
||||
completely different.
|
||||
|
||||
This module turns those per-photo hashes into explicit *groups*. The
|
||||
result is persisted in two columns:
|
||||
|
||||
* `Photo.duplicate_group_id` — shared by every member of a group
|
||||
* `Photo.is_duplicate` — derived: True iff group_id IS NOT NULL
|
||||
(kept as a column so the existing
|
||||
PhotoThumbnail badge and /library/stats
|
||||
count don't have to change).
|
||||
|
||||
The grouping is recomputed in batches by `regroup_duplicates`, NOT on
|
||||
individual writes — that lets us use a single in-memory pass instead of
|
||||
maintaining a per-row similarity index. Triggered automatically after
|
||||
each scan and on demand from the Settings panel.
|
||||
|
||||
Complexity
|
||||
----------
|
||||
Pairwise O(N²) over photos with a non-null phash. At ~5 µs per Hamming
|
||||
distance in CPython this is roughly:
|
||||
|
||||
1k photos → ~5 s
|
||||
5k photos → ~125 s
|
||||
10k photos → ~500 s
|
||||
|
||||
That's the wrong shape for libraries past ~5k. The drop-in replacement
|
||||
is a BK-tree (e.g. `pybktree`) which gives O(log N) lookups for a fixed
|
||||
Hamming threshold; swap it in here when someone trips the limit. The
|
||||
public function signature stays the same.
|
||||
|
||||
Out of scope (deferred)
|
||||
-----------------------
|
||||
* Dismissing a group / "intentional duplicates" — would need a per-group
|
||||
or per-pair flag plus a skip-set in this function so re-grouping
|
||||
doesn't bring them back. Add when there's a real user need.
|
||||
* Incremental updates on individual photo writes — currently we just
|
||||
re-run the whole job after each scan, which is fine while the cost is
|
||||
bounded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.photos import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Hamming distance threshold under which two phashes are considered
|
||||
# "the same image". 6 bits out of 64 is the rule-of-thumb sweet spot for
|
||||
# pHash — tight enough to avoid false positives between unrelated photos,
|
||||
# loose enough to catch JPEG re-encodes, slight crops, and a screenshot
|
||||
# of a screenshot.
|
||||
DEFAULT_THRESHOLD = 6
|
||||
|
||||
|
||||
def _hex_to_int(h: str) -> int:
|
||||
"""Parse a 16-char hex pHash to a Python int. Returns -1 on bad input
|
||||
so the pairwise loop can skip the row without raising."""
|
||||
try:
|
||||
return int(h, 16)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
|
||||
|
||||
def _hamming(a: int, b: int) -> int:
|
||||
"""Population count of XOR — the canonical hash distance metric.
|
||||
`int.bit_count()` is C-implemented in CPython 3.10+ and is by far
|
||||
the fastest path; the `bin(...).count('1')` fallback is here only
|
||||
so the function still works on older interpreters."""
|
||||
x = a ^ b
|
||||
try:
|
||||
return x.bit_count() # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
return bin(x).count('1')
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
"""Tiny union-find / disjoint-set used to merge similar phashes into
|
||||
connected components. Inlined here (rather than pulled from a dep)
|
||||
because it's ~15 lines and we don't need anything fancy."""
|
||||
|
||||
def __init__(self, n: int) -> None:
|
||||
self.parent = list(range(n))
|
||||
self.rank = [0] * n
|
||||
|
||||
def find(self, x: int) -> int:
|
||||
# Path compression — flattens the tree on lookup so subsequent
|
||||
# finds are amortized O(α(N)) ≈ O(1).
|
||||
while self.parent[x] != x:
|
||||
self.parent[x] = self.parent[self.parent[x]]
|
||||
x = self.parent[x]
|
||||
return x
|
||||
|
||||
def union(self, a: int, b: int) -> None:
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra == rb:
|
||||
return
|
||||
if self.rank[ra] < self.rank[rb]:
|
||||
ra, rb = rb, ra
|
||||
self.parent[rb] = ra
|
||||
if self.rank[ra] == self.rank[rb]:
|
||||
self.rank[ra] += 1
|
||||
|
||||
|
||||
async def regroup_duplicates(threshold: int = DEFAULT_THRESHOLD) -> dict:
|
||||
"""Recompute every photo's duplicate_group_id from current phashes.
|
||||
|
||||
Idempotent — safe to call as often as you like. Returns a small
|
||||
summary dict the maintenance endpoint surfaces back to the UI.
|
||||
|
||||
Photos that end up alone in a component (size 1) get
|
||||
`duplicate_group_id=NULL` and `is_duplicate=False`. This is what
|
||||
cleans up "dead" groups after the user discards N-1 members from
|
||||
one.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Pull (id, phash) for every non-discarded photo with a hash.
|
||||
# Discarded photos are excluded so we don't keep showing groups
|
||||
# made up of trashed copies.
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Photo.id, Photo.phash)
|
||||
.where(Photo.phash.is_not(None))
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
)
|
||||
).all()
|
||||
|
||||
n = len(rows)
|
||||
if n == 0:
|
||||
# Still need to clear stale group_ids in case the user just
|
||||
# discarded the last surviving member of every group.
|
||||
await _clear_all_groups(session)
|
||||
await session.commit()
|
||||
return {
|
||||
'photos_considered': 0,
|
||||
'groups': 0,
|
||||
'members': 0,
|
||||
}
|
||||
|
||||
ids: list[str] = [row[0] for row in rows]
|
||||
hashes: list[int] = [_hex_to_int(row[1]) for row in rows]
|
||||
|
||||
uf = _UnionFind(n)
|
||||
|
||||
# O(N²) pairwise comparison. See module docstring for the
|
||||
# scaling analysis and the BK-tree upgrade path.
|
||||
for i in range(n):
|
||||
hi = hashes[i]
|
||||
if hi < 0:
|
||||
continue
|
||||
for j in range(i + 1, n):
|
||||
hj = hashes[j]
|
||||
if hj < 0:
|
||||
continue
|
||||
if _hamming(hi, hj) <= threshold:
|
||||
uf.union(i, j)
|
||||
|
||||
# Collect components. Each connected component of size >= 2 gets
|
||||
# a fresh group id; size-1 components are intentionally dropped.
|
||||
components: dict[int, list[int]] = {}
|
||||
for i in range(n):
|
||||
root = uf.find(i)
|
||||
components.setdefault(root, []).append(i)
|
||||
|
||||
# First pass: clear EVERY photo's group_id so survivors of an
|
||||
# earlier grouping that no longer match anyone end up clean. This
|
||||
# is one bulk UPDATE rather than per-photo to keep the cost low
|
||||
# even on big libraries.
|
||||
await _clear_all_groups(session)
|
||||
|
||||
# Second pass: write the new group ids for components of size 2+.
|
||||
groups_created = 0
|
||||
members_total = 0
|
||||
for members in components.values():
|
||||
if len(members) < 2:
|
||||
continue
|
||||
group_id = str(uuid.uuid4())
|
||||
member_ids = [ids[i] for i in members]
|
||||
await session.execute(
|
||||
update(Photo)
|
||||
.where(Photo.id.in_(member_ids))
|
||||
.values(
|
||||
duplicate_group_id=group_id,
|
||||
is_duplicate=True,
|
||||
)
|
||||
)
|
||||
groups_created += 1
|
||||
members_total += len(member_ids)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"regroup_duplicates: considered {n} photos, "
|
||||
f"created {groups_created} group(s) covering {members_total} member(s)"
|
||||
)
|
||||
|
||||
return {
|
||||
'photos_considered': n,
|
||||
'groups': groups_created,
|
||||
'members': members_total,
|
||||
}
|
||||
|
||||
|
||||
async def _clear_all_groups(session) -> None:
|
||||
"""Reset duplicate_group_id / is_duplicate on every photo. Used as
|
||||
the first half of a regroup pass so photos that no longer cluster
|
||||
with anyone end up clean instead of carrying a stale group id."""
|
||||
await session.execute(
|
||||
update(Photo).values(duplicate_group_id=None, is_duplicate=False)
|
||||
)
|
||||
@@ -1,278 +0,0 @@
|
||||
"""
|
||||
Metadata extraction service using ExifTool
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
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
|
||||
|
||||
_DMS_RE = re.compile(
|
||||
r"""\s*
|
||||
(?P<deg>-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s*
|
||||
(?:(?P<min>\d+(?:\.\d+)?)\s*[\'’m]?\s*)?
|
||||
(?:(?P<sec>\d+(?:\.\d+)?)\s*[\"”s]?\s*)?
|
||||
(?P<ref>[NSEW])?\s*$""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_coord(value, ref: str | None) -> float | None:
|
||||
"""Coerce a single GPS coordinate from any form ExifTool may emit.
|
||||
|
||||
ExifTool's ``-j`` JSON output applies print conversion by default, so
|
||||
coordinates can come back as:
|
||||
|
||||
* a number (``48.1278``) — happens for some sources / when ``-n`` is set
|
||||
* a plain DMS string (``"48 deg 7' 39.96\\""``) — bare ``EXIF:GPSLatitude``
|
||||
* a DMS-with-ref string (``"48 deg 7' 39.96\\" N"``) — ``Composite:GPSLatitude``
|
||||
|
||||
The optional ``ref`` argument lets the caller pass an explicit
|
||||
``GPSLatitudeRef`` / ``GPSLongitudeRef`` ('N'/'S'/'E'/'W') when the
|
||||
string itself doesn't carry one. Returns signed decimal degrees, or
|
||||
``None`` if the value is unparseable.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# Numeric path — already decimal degrees, possibly already signed.
|
||||
if isinstance(value, (int, float)):
|
||||
out = float(value)
|
||||
else:
|
||||
m = _DMS_RE.match(str(value))
|
||||
if not m:
|
||||
return None
|
||||
deg = float(m.group('deg'))
|
||||
minutes = float(m.group('min') or 0)
|
||||
seconds = float(m.group('sec') or 0)
|
||||
out = abs(deg) + minutes / 60.0 + seconds / 3600.0
|
||||
if deg < 0:
|
||||
out = -out
|
||||
embedded_ref = m.group('ref')
|
||||
if embedded_ref:
|
||||
ref = embedded_ref
|
||||
if ref:
|
||||
r = ref[0].upper()
|
||||
if r in ('S', 'W'):
|
||||
out = -abs(out)
|
||||
elif r in ('N', 'E'):
|
||||
out = abs(out)
|
||||
return out
|
||||
|
||||
|
||||
def extract_gps(exif_data: Dict) -> tuple:
|
||||
"""Return (lat, lon) in signed decimal degrees, or (None, None).
|
||||
|
||||
With ``exiftool -G -j`` GPS values are keyed under their group.
|
||||
``Composite:GPSLatitude`` / ``Composite:GPSLongitude`` carry the
|
||||
hemisphere reference inline (``"48 deg 7' 39.96\\" N"``) while the bare
|
||||
``EXIF:GPSLatitude`` / ``EXIF:GPSLongitude`` need the separate
|
||||
``EXIF:GPSLatitudeRef`` / ``EXIF:GPSLongitudeRef`` to know the sign.
|
||||
|
||||
Pre-fix this function read the *unprefixed* keys ``GPSLatitude`` /
|
||||
``GPSLongitude`` (which never exist in ``-G`` output) AND assumed
|
||||
they were already floats — so it silently dropped every photo's GPS.
|
||||
"""
|
||||
lat = _parse_coord(exif_data.get('Composite:GPSLatitude'), None)
|
||||
lon = _parse_coord(exif_data.get('Composite:GPSLongitude'), None)
|
||||
if lat is None or lon is None:
|
||||
lat = _parse_coord(
|
||||
exif_data.get('EXIF:GPSLatitude'),
|
||||
exif_data.get('EXIF:GPSLatitudeRef'),
|
||||
)
|
||||
lon = _parse_coord(
|
||||
exif_data.get('EXIF:GPSLongitude'),
|
||||
exif_data.get('EXIF:GPSLongitudeRef'),
|
||||
)
|
||||
if lat is None or lon is None:
|
||||
return None, None
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
return None, None
|
||||
# Some cameras emit (0, 0) when they have no GPS lock — treat as missing
|
||||
if lat == 0 and lon == 0:
|
||||
return None, None
|
||||
return lat, lon
|
||||
|
||||
|
||||
def extract_key_metadata(exif_data: Dict) -> Dict:
|
||||
"""Extract key metadata fields for FTS indexing"""
|
||||
key_fields = []
|
||||
|
||||
# Camera information
|
||||
if 'EXIF:Make' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Make'])
|
||||
if 'EXIF:Model' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Model'])
|
||||
if 'EXIF:LensModel' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:LensModel'])
|
||||
|
||||
# Location information
|
||||
lat, lon = extract_gps(exif_data)
|
||||
if lat is not None and lon is not None:
|
||||
key_fields.append(f"GPS: {lat}, {lon}")
|
||||
|
||||
# IPTC/XMP keywords
|
||||
keywords = exif_data.get('IPTC:Keywords') or exif_data.get('XMP:Subject')
|
||||
if keywords:
|
||||
if isinstance(keywords, list):
|
||||
key_fields.extend(keywords)
|
||||
else:
|
||||
key_fields.append(keywords)
|
||||
|
||||
# Copyright and creator
|
||||
if 'EXIF:Copyright' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Copyright'])
|
||||
if 'XMP:Creator' in exif_data:
|
||||
key_fields.append(exif_data['XMP:Creator'])
|
||||
if 'EXIF:Artist' in exif_data:
|
||||
key_fields.append(exif_data['EXIF:Artist'])
|
||||
|
||||
return {
|
||||
'exif_text': ' '.join(str(f) for f in key_fields),
|
||||
'camera_make': exif_data.get('EXIF:Make'),
|
||||
'camera_model': exif_data.get('EXIF:Model'),
|
||||
'lens_model': exif_data.get('EXIF:LensModel'),
|
||||
'gps_latitude': lat,
|
||||
'gps_longitude': lon,
|
||||
}
|
||||
|
||||
@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 GPS coordinates into first-class columns so the
|
||||
# Map view can query them without parsing exif_json.
|
||||
lat, lon = extract_gps(exif_data)
|
||||
photo.latitude = lat
|
||||
photo.longitude = lon
|
||||
|
||||
# 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,140 +0,0 @@
|
||||
"""
|
||||
Unified search service — hybrid FTS + semantic (RRF) search.
|
||||
|
||||
Phase 1 (PR4): semantic-only via pgvector cosine similarity.
|
||||
Phase 2 (PR5): adds FTS via tsvector, enables RRF fusion.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import select, text, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Photo
|
||||
from app.models.embeddings import Embedding
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def hybrid_search(
|
||||
db: AsyncSession,
|
||||
q: Optional[str] = None,
|
||||
tag_ids: Optional[list[str]] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Run hybrid search (FTS + semantic) with RRF fusion.
|
||||
|
||||
Currently semantic-only; FTS leg added in PR5.
|
||||
"""
|
||||
model_name = settings.vision.embedder.name
|
||||
results = {}
|
||||
|
||||
# ── Semantic search (CLIP text → pgvector cosine) ─────────────────
|
||||
if q:
|
||||
try:
|
||||
from app.services.vision.registry import registry
|
||||
embedder = registry.get_embedder()
|
||||
query_vec = embedder.embed_text(q)
|
||||
|
||||
# pgvector cosine distance: <=> returns distance (lower = closer)
|
||||
vec_str = "[" + ",".join(str(float(v)) for v in query_vec) + "]"
|
||||
stmt = text("""
|
||||
SELECT e.photo_id,
|
||||
(e.vector <=> :qvec::vector) AS distance
|
||||
FROM embeddings e
|
||||
WHERE e.model = :model
|
||||
ORDER BY e.vector <=> :qvec::vector
|
||||
LIMIT 200
|
||||
""")
|
||||
rows = (await db.execute(stmt, {"qvec": vec_str, "model": model_name})).fetchall()
|
||||
|
||||
for rank, (photo_id, distance) in enumerate(rows):
|
||||
if photo_id not in results:
|
||||
results[photo_id] = {"semantic_rank": rank, "fts_rank": None}
|
||||
else:
|
||||
results[photo_id]["semantic_rank"] = rank
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Semantic search failed (models may not be loaded): %s", e)
|
||||
|
||||
# ── FTS search (photos.search_vector + ocr_text) ────────────────
|
||||
if q:
|
||||
try:
|
||||
fts_stmt = text("""
|
||||
SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
|
||||
FROM photos
|
||||
WHERE search_vector @@ plainto_tsquery('english', :q)
|
||||
UNION
|
||||
SELECT o.photo_id AS id,
|
||||
MAX(o.confidence) AS rank
|
||||
FROM ocr_text o
|
||||
WHERE to_tsvector('english', o.text) @@ plainto_tsquery('english', :q)
|
||||
GROUP BY o.photo_id
|
||||
ORDER BY rank DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
fts_rows = (await db.execute(fts_stmt, {"q": q})).fetchall()
|
||||
for rank, (photo_id, score) in enumerate(fts_rows):
|
||||
if photo_id not in results:
|
||||
results[photo_id] = {"semantic_rank": None, "fts_rank": rank}
|
||||
else:
|
||||
results[photo_id]["fts_rank"] = rank
|
||||
except Exception as e:
|
||||
logger.warning("FTS search failed: %s", e)
|
||||
|
||||
# ── RRF fusion ────────────────────────────────────────────────────
|
||||
k = 60
|
||||
scored = []
|
||||
for photo_id, ranks in results.items():
|
||||
score = 0.0
|
||||
if ranks["semantic_rank"] is not None:
|
||||
score += 1.0 / (k + ranks["semantic_rank"])
|
||||
if ranks.get("fts_rank") is not None:
|
||||
score += 1.0 / (k + ranks["fts_rank"])
|
||||
scored.append((photo_id, score))
|
||||
|
||||
scored.sort(key=lambda x: -x[1])
|
||||
|
||||
# If no text query, fall back to recent photos
|
||||
if not q:
|
||||
if tag_ids:
|
||||
from app.models.tags import photo_tags
|
||||
# Subquery to get distinct photo_ids matching the tag filter
|
||||
sub = select(photo_tags.c.photo_id).where(
|
||||
photo_tags.c.tag_id.in_(tag_ids)
|
||||
).distinct().subquery()
|
||||
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
|
||||
else:
|
||||
stmt = select(Photo.id)
|
||||
stmt = stmt.order_by(Photo.added_at.desc())
|
||||
if date_from:
|
||||
stmt = stmt.where(Photo.taken_at >= date_from)
|
||||
if date_to:
|
||||
stmt = stmt.where(Photo.taken_at <= date_to)
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
rows = (await db.execute(stmt)).fetchall()
|
||||
return [{"photo_id": row[0], "score": 0.0} for row in rows]
|
||||
|
||||
# Apply filters to scored results
|
||||
photo_ids = [pid for pid, _ in scored]
|
||||
if not photo_ids:
|
||||
return []
|
||||
|
||||
# Filter by tags if requested
|
||||
if tag_ids:
|
||||
from app.models.tags import photo_tags
|
||||
stmt = select(photo_tags.c.photo_id).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
).distinct()
|
||||
valid_ids = {row[0] for row in (await db.execute(stmt)).fetchall()}
|
||||
scored = [(pid, s) for pid, s in scored if pid in valid_ids]
|
||||
|
||||
# Paginate
|
||||
page = scored[offset : offset + limit]
|
||||
return [{"photo_id": pid, "score": score} for pid, score in page]
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Vision pipeline services — embedding, OCR, object detection, face recognition.
|
||||
|
||||
All inference is done through the ModelRegistry singleton, which lazy-loads
|
||||
ONNX Runtime sessions on first use and caches them for the lifetime of the
|
||||
worker process.
|
||||
"""
|
||||
@@ -1,105 +0,0 @@
|
||||
"""
|
||||
Abstract base classes for vision backends.
|
||||
|
||||
Each ABC defines the contract a backend must satisfy. The default
|
||||
implementation is ONNXBackend (onnx_backend.py). A ROCm backend can be
|
||||
added later by subclassing these ABCs and registering via
|
||||
settings.vision.backend.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionBox:
|
||||
"""A single object detection result."""
|
||||
label: str
|
||||
confidence: float
|
||||
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRResult:
|
||||
"""A single OCR text region."""
|
||||
text: str
|
||||
confidence: float
|
||||
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
|
||||
language: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FaceDetection:
|
||||
"""A detected face with its recognition embedding."""
|
||||
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
|
||||
embedding: np.ndarray # float32 vector (128-d for SFace)
|
||||
quality: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
"""A content-type classification."""
|
||||
label: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class Embedder(ABC):
|
||||
"""Generates image and text embeddings (e.g. OpenCLIP ViT-B/32)."""
|
||||
|
||||
@abstractmethod
|
||||
def embed_image(self, image: np.ndarray) -> np.ndarray:
|
||||
"""Return a normalized float32 embedding vector for an RGB image."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def embed_text(self, text: str) -> np.ndarray:
|
||||
"""Return a normalized float32 embedding vector for a text query."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def dim(self) -> int:
|
||||
"""Dimensionality of the output embedding."""
|
||||
...
|
||||
|
||||
|
||||
class OCREngine(ABC):
|
||||
"""Extracts text from images (e.g. rapidocr-onnxruntime)."""
|
||||
|
||||
@abstractmethod
|
||||
def run(self, image: np.ndarray) -> list[OCRResult]:
|
||||
"""Return OCR results for an RGB image."""
|
||||
...
|
||||
|
||||
|
||||
class ObjectDetector(ABC):
|
||||
"""Detects objects in images (e.g. YOLOv8n)."""
|
||||
|
||||
@abstractmethod
|
||||
def detect(self, image: np.ndarray) -> list[DetectionBox]:
|
||||
"""Return detections for an RGB image."""
|
||||
...
|
||||
|
||||
|
||||
class ContentClassifier(ABC):
|
||||
"""Classifies images into content types (screenshot, document, etc.)."""
|
||||
|
||||
@abstractmethod
|
||||
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
|
||||
"""Return content type classifications for an RGB image."""
|
||||
...
|
||||
|
||||
|
||||
class FaceProcessor(ABC):
|
||||
"""Detects faces and extracts recognition embeddings (e.g. YuNet + SFace)."""
|
||||
|
||||
@abstractmethod
|
||||
def process(self, image: np.ndarray) -> list[FaceDetection]:
|
||||
"""Return face detections with embeddings for an RGB image."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def embedding_dim(self) -> int:
|
||||
"""Dimensionality of face embedding vectors."""
|
||||
...
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Download vision model weights on first worker boot.
|
||||
|
||||
Run as: python -m app.services.vision.bootstrap_models
|
||||
|
||||
Or called from the vision worker entrypoint before Celery starts.
|
||||
Downloads are idempotent — existing files are skipped.
|
||||
|
||||
For models that require export (OpenCLIP, YOLOv8n), see export_models.py.
|
||||
Those must be exported once on any machine with pip, then placed in
|
||||
the models volume before the worker starts.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (relative_path, url, description)
|
||||
# Models with url=None must be pre-exported via export_models.py.
|
||||
# InsightFace (RetinaFace + ArcFace) auto-downloads via the insightface
|
||||
# package on first use — no manual download entries needed.
|
||||
DOWNLOADS = []
|
||||
|
||||
# Models that need manual export via export_models.py
|
||||
EXPORTS = [
|
||||
("embed/visual.onnx", "OpenCLIP ViT-B/32 visual encoder"),
|
||||
("embed/textual.onnx", "OpenCLIP ViT-B/32 textual encoder"),
|
||||
("detect/yolov8n.onnx", "YOLOv8n object detector"),
|
||||
]
|
||||
|
||||
|
||||
def bootstrap(models_dir: str | None = None):
|
||||
"""Ensure all model files are present. Download what we can, warn about
|
||||
files that need manual export."""
|
||||
base = Path(models_dir or settings.vision.models_dir)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download auto-downloadable models
|
||||
for rel_path, url, desc in DOWNLOADS:
|
||||
dest = base / rel_path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dest.exists():
|
||||
logger.debug("Already exists: %s (%s)", dest, desc)
|
||||
continue
|
||||
|
||||
logger.info("Downloading %s → %s", desc, dest)
|
||||
try:
|
||||
urlretrieve(url, str(dest))
|
||||
size_kb = dest.stat().st_size / 1024
|
||||
logger.info("Downloaded %s (%.0f KB)", desc, size_kb)
|
||||
except Exception as e:
|
||||
logger.error("Failed to download %s: %s", desc, e)
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
|
||||
# Check for manually-exported models
|
||||
missing = []
|
||||
for rel_path, desc in EXPORTS:
|
||||
dest = base / rel_path
|
||||
if not dest.exists():
|
||||
missing.append((rel_path, desc))
|
||||
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Missing %d model(s) that require manual export via export_models.py:",
|
||||
len(missing),
|
||||
)
|
||||
for rel_path, desc in missing:
|
||||
logger.warning(" %s — %s", base / rel_path, desc)
|
||||
logger.warning(
|
||||
"Run: python -m app.services.vision.export_models --models-dir %s",
|
||||
base,
|
||||
)
|
||||
else:
|
||||
logger.info("All model files present in %s", base)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
bootstrap()
|
||||
@@ -1,106 +0,0 @@
|
||||
"""
|
||||
CLIP zero-shot content-type classifier.
|
||||
|
||||
Uses the native OpenCLIP PyTorch text encoder for high-quality text
|
||||
embeddings (the ONNX text encoder has degraded quality due to the
|
||||
eot_indices workaround). Image embeddings use the ONNX visual encoder
|
||||
which works well.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import ContentClassifier, ClassificationResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATEGORY_PROMPTS = {
|
||||
"screenshot": [
|
||||
"a screenshot of a computer screen",
|
||||
"a screenshot of a phone screen",
|
||||
"a screen capture of a user interface",
|
||||
],
|
||||
"document": [
|
||||
"a scanned document",
|
||||
"a photo of a document with printed text",
|
||||
"a photo of a page of text on paper",
|
||||
],
|
||||
"receipt": [
|
||||
"a photo of a receipt",
|
||||
"a photo of a bill or invoice",
|
||||
],
|
||||
"meme": [
|
||||
"an internet meme with text overlay",
|
||||
"a funny image with caption text",
|
||||
],
|
||||
"artwork": [
|
||||
"a painting or drawing",
|
||||
"a sketch or illustration",
|
||||
"digital art or graphic design",
|
||||
],
|
||||
"photograph": [
|
||||
"a photograph taken with a camera",
|
||||
"a real photo of a real scene or person",
|
||||
"a candid photograph",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CLIPContentClassifier(ContentClassifier):
|
||||
"""Zero-shot content classifier using CLIP text-image similarity.
|
||||
Uses native PyTorch for text encoding, ONNX for image encoding."""
|
||||
|
||||
def __init__(self, settings: VisionSettings):
|
||||
import open_clip
|
||||
|
||||
self._min_confidence = settings.classifier.min_confidence
|
||||
|
||||
# Load native model for text encoding only
|
||||
logger.info("Loading OpenCLIP text encoder for content classification")
|
||||
model, _, _ = open_clip.create_model_and_transforms(
|
||||
"ViT-B-32", pretrained="laion2b_s34b_b79k"
|
||||
)
|
||||
model.eval()
|
||||
self._model = model
|
||||
self._tokenizer = open_clip.get_tokenizer("ViT-B-32")
|
||||
|
||||
# Get the ONNX image embedder from the registry
|
||||
from app.services.vision.registry import registry
|
||||
self._embedder = registry.get_embedder()
|
||||
|
||||
# Pre-compute text embeddings for each category
|
||||
self._category_embeddings: dict[str, np.ndarray] = {}
|
||||
for category, prompts in CATEGORY_PROMPTS.items():
|
||||
tokens = self._tokenizer(prompts)
|
||||
with torch.no_grad():
|
||||
text_features = model.encode_text(tokens)
|
||||
text_features /= text_features.norm(dim=-1, keepdim=True)
|
||||
avg = text_features.mean(dim=0)
|
||||
avg /= avg.norm()
|
||||
self._category_embeddings[category] = avg.numpy().astype(np.float32)
|
||||
|
||||
logger.info("Content classifier ready with %d categories", len(self._category_embeddings))
|
||||
|
||||
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
|
||||
img_vec = self._embedder.embed_image(image)
|
||||
|
||||
# Cosine similarity against each category
|
||||
scores = {}
|
||||
for category, cat_vec in self._category_embeddings.items():
|
||||
scores[category] = float(np.dot(img_vec, cat_vec))
|
||||
|
||||
# Sort by score descending
|
||||
ranked = sorted(scores.items(), key=lambda x: -x[1])
|
||||
best_cat, best_score = ranked[0]
|
||||
second_score = ranked[1][1]
|
||||
|
||||
margin = best_score - second_score
|
||||
# Normalize: 0.01 margin → ~0.5 confidence, 0.03+ → ~1.0
|
||||
confidence = min(1.0, margin * 30)
|
||||
|
||||
if confidence >= self._min_confidence:
|
||||
return [ClassificationResult(label=best_cat, confidence=confidence)]
|
||||
|
||||
return []
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Face embedding clustering using DBSCAN with cosine distance.
|
||||
|
||||
Called by the periodic `recluster_faces` Celery task (PR7).
|
||||
"""
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from sklearn.cluster import DBSCAN
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cluster_faces(
|
||||
embeddings: np.ndarray,
|
||||
eps: float = 0.35,
|
||||
min_samples: int = 2,
|
||||
) -> np.ndarray:
|
||||
"""Cluster face embeddings using DBSCAN with cosine metric.
|
||||
|
||||
Args:
|
||||
embeddings: (N, D) float32 array of L2-normalized face embeddings.
|
||||
eps: Maximum cosine distance between two samples to be in the
|
||||
same neighborhood. Lower = tighter clusters.
|
||||
min_samples: Minimum cluster size.
|
||||
|
||||
Returns:
|
||||
(N,) int array of cluster labels. -1 = noise / unclustered.
|
||||
"""
|
||||
if len(embeddings) < min_samples:
|
||||
return np.full(len(embeddings), -1, dtype=int)
|
||||
|
||||
db = DBSCAN(eps=eps, min_samples=min_samples, metric="cosine")
|
||||
labels = db.fit_predict(embeddings)
|
||||
|
||||
n_clusters = len(set(labels) - {-1})
|
||||
n_noise = (labels == -1).sum()
|
||||
logger.info(
|
||||
"Face clustering: %d embeddings → %d clusters, %d noise",
|
||||
len(embeddings), n_clusters, n_noise,
|
||||
)
|
||||
return labels
|
||||
@@ -1,140 +0,0 @@
|
||||
"""
|
||||
YOLOv8n object detector using raw ONNX Runtime.
|
||||
|
||||
Expects {models_dir}/detect/yolov8n.onnx, exported from ultralytics
|
||||
via bootstrap_models.py. We do NOT ship ultralytics at runtime to
|
||||
avoid dragging in torch.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import ObjectDetector, DetectionBox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INPUT_SIZE = 640
|
||||
|
||||
# COCO class names (80 classes)
|
||||
COCO_LABELS = [
|
||||
"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train",
|
||||
"truck", "boat", "traffic light", "fire hydrant", "stop sign",
|
||||
"parking meter", "bench", "bird", "cat", "dog", "horse", "sheep",
|
||||
"cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella",
|
||||
"handbag", "tie", "suitcase", "frisbee", "skis", "snowboard",
|
||||
"sports ball", "kite", "baseball bat", "baseball glove", "skateboard",
|
||||
"surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork",
|
||||
"knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange",
|
||||
"broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
|
||||
"couch", "potted plant", "bed", "dining table", "toilet", "tv",
|
||||
"laptop", "mouse", "remote", "keyboard", "cell phone", "microwave",
|
||||
"oven", "toaster", "sink", "refrigerator", "book", "clock", "vase",
|
||||
"scissors", "teddy bear", "hair drier", "toothbrush",
|
||||
]
|
||||
|
||||
|
||||
def _preprocess(image: np.ndarray) -> tuple[np.ndarray, float, float]:
|
||||
"""Letterbox-resize + normalize to NCHW float32. Returns input tensor
|
||||
and scale factors for mapping boxes back to original coords."""
|
||||
from PIL import Image
|
||||
|
||||
img = Image.fromarray(image).convert("RGB")
|
||||
orig_w, orig_h = img.size
|
||||
|
||||
scale = min(_INPUT_SIZE / orig_w, _INPUT_SIZE / orig_h)
|
||||
new_w = int(orig_w * scale)
|
||||
new_h = int(orig_h * scale)
|
||||
img = img.resize((new_w, new_h), Image.BICUBIC)
|
||||
|
||||
# Paste onto gray canvas
|
||||
canvas = np.full((_INPUT_SIZE, _INPUT_SIZE, 3), 114, dtype=np.uint8)
|
||||
pad_x = (_INPUT_SIZE - new_w) // 2
|
||||
pad_y = (_INPUT_SIZE - new_h) // 2
|
||||
canvas[pad_y : pad_y + new_h, pad_x : pad_x + new_w] = np.array(img)
|
||||
|
||||
blob = canvas.astype(np.float32) / 255.0
|
||||
blob = blob.transpose(2, 0, 1)[np.newaxis] # NCHW
|
||||
return blob, scale, pad_x, pad_y
|
||||
|
||||
|
||||
def _postprocess(
|
||||
outputs: np.ndarray,
|
||||
scale: float,
|
||||
pad_x: int,
|
||||
pad_y: int,
|
||||
orig_w: int,
|
||||
orig_h: int,
|
||||
conf_threshold: float,
|
||||
max_detections: int,
|
||||
) -> list[DetectionBox]:
|
||||
"""Parse YOLOv8 output (1, 84, N) → list of DetectionBox."""
|
||||
# outputs shape: (1, 84, N) where 84 = 4 box coords + 80 class scores
|
||||
preds = outputs[0] # (84, N)
|
||||
preds = preds.T # (N, 84)
|
||||
|
||||
boxes_xywh = preds[:, :4]
|
||||
scores = preds[:, 4:]
|
||||
|
||||
class_ids = np.argmax(scores, axis=1)
|
||||
confidences = scores[np.arange(len(scores)), class_ids]
|
||||
|
||||
mask = confidences >= conf_threshold
|
||||
boxes_xywh = boxes_xywh[mask]
|
||||
class_ids = class_ids[mask]
|
||||
confidences = confidences[mask]
|
||||
|
||||
if len(confidences) == 0:
|
||||
return []
|
||||
|
||||
# Sort by confidence, take top N
|
||||
order = np.argsort(-confidences)[:max_detections]
|
||||
boxes_xywh = boxes_xywh[order]
|
||||
class_ids = class_ids[order]
|
||||
confidences = confidences[order]
|
||||
|
||||
results = []
|
||||
for i in range(len(confidences)):
|
||||
cx, cy, w, h = boxes_xywh[i]
|
||||
# Remove letterbox padding and rescale to original image
|
||||
x1 = (cx - w / 2 - pad_x) / scale
|
||||
y1 = (cy - h / 2 - pad_y) / scale
|
||||
x2 = (cx + w / 2 - pad_x) / scale
|
||||
y2 = (cy + h / 2 - pad_y) / scale
|
||||
# Normalize to 0-1
|
||||
bbox = [
|
||||
max(0, x1 / orig_w),
|
||||
max(0, y1 / orig_h),
|
||||
min(1, x2 / orig_w),
|
||||
min(1, y2 / orig_h),
|
||||
]
|
||||
label = COCO_LABELS[class_ids[i]] if class_ids[i] < len(COCO_LABELS) else f"class_{class_ids[i]}"
|
||||
results.append(DetectionBox(label=label, confidence=float(confidences[i]), bbox=bbox))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class YOLOv8Detector(ObjectDetector):
|
||||
def __init__(self, settings: VisionSettings):
|
||||
model_path = Path(settings.models_dir) / "detect" / "yolov8n.onnx"
|
||||
|
||||
opts = ort.SessionOptions()
|
||||
opts.inter_op_num_threads = 2
|
||||
opts.intra_op_num_threads = 2
|
||||
|
||||
logger.info("Loading YOLOv8n from %s", model_path)
|
||||
self._session = ort.InferenceSession(str(model_path), opts, providers=["CPUExecutionProvider"])
|
||||
self._conf_threshold = settings.detector.min_confidence
|
||||
self._max_detections = settings.detector.max_detections
|
||||
|
||||
def detect(self, image: np.ndarray) -> list[DetectionBox]:
|
||||
orig_h, orig_w = image.shape[:2]
|
||||
blob, scale, pad_x, pad_y = _preprocess(image)
|
||||
input_name = self._session.get_inputs()[0].name
|
||||
outputs = self._session.run(None, {input_name: blob})[0]
|
||||
return _postprocess(
|
||||
outputs, scale, pad_x, pad_y, orig_w, orig_h,
|
||||
self._conf_threshold, self._max_detections,
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
OpenCLIP ViT-B/32 embedder using ONNX Runtime.
|
||||
|
||||
Expects two ONNX files under {models_dir}/embed/:
|
||||
- visual.onnx (image encoder)
|
||||
- textual.onnx (text encoder)
|
||||
|
||||
These are exported from open_clip via bootstrap_models.py.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import Embedder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OpenCLIP ViT-B/32 preprocessing constants (ImageNet norm)
|
||||
_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
|
||||
_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)
|
||||
_INPUT_SIZE = 224
|
||||
|
||||
|
||||
def _preprocess_image(image: np.ndarray) -> np.ndarray:
|
||||
"""Resize, center-crop, normalize an RGB uint8 image to NCHW float32."""
|
||||
from PIL import Image
|
||||
|
||||
img = Image.fromarray(image).convert("RGB")
|
||||
# Resize shortest edge to _INPUT_SIZE, then center crop
|
||||
w, h = img.size
|
||||
scale = _INPUT_SIZE / min(w, h)
|
||||
img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
|
||||
w, h = img.size
|
||||
left = (w - _INPUT_SIZE) // 2
|
||||
top = (h - _INPUT_SIZE) // 2
|
||||
img = img.crop((left, top, left + _INPUT_SIZE, top + _INPUT_SIZE))
|
||||
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
arr = (arr - _MEAN) / _STD
|
||||
arr = arr.transpose(2, 0, 1) # HWC → CHW
|
||||
return arr[np.newaxis] # NCHW
|
||||
|
||||
|
||||
class OpenCLIPEmbedder(Embedder):
|
||||
def __init__(self, settings: VisionSettings):
|
||||
model_dir = Path(settings.models_dir) / "embed"
|
||||
visual_path = model_dir / "visual.onnx"
|
||||
textual_path = model_dir / "textual.onnx"
|
||||
|
||||
opts = ort.SessionOptions()
|
||||
opts.inter_op_num_threads = 2
|
||||
opts.intra_op_num_threads = 2
|
||||
|
||||
logger.info("Loading visual encoder from %s", visual_path)
|
||||
self._visual = ort.InferenceSession(str(visual_path), opts, providers=["CPUExecutionProvider"])
|
||||
|
||||
logger.info("Loading textual encoder from %s", textual_path)
|
||||
self._textual = ort.InferenceSession(str(textual_path), opts, providers=["CPUExecutionProvider"])
|
||||
|
||||
def embed_image(self, image: np.ndarray) -> np.ndarray:
|
||||
inp = _preprocess_image(image)
|
||||
input_name = self._visual.get_inputs()[0].name
|
||||
out = self._visual.run(None, {input_name: inp})[0][0]
|
||||
out = out / np.linalg.norm(out)
|
||||
return out.astype(np.float32)
|
||||
|
||||
def embed_text(self, text: str) -> np.ndarray:
|
||||
import open_clip
|
||||
tokenizer = open_clip.get_tokenizer("ViT-B-32")
|
||||
tokens = tokenizer([text]).numpy().astype(np.int64)
|
||||
# Compute EOT indices outside ONNX (avoids ArgMax(13) op)
|
||||
eot_indices = tokens.argmax(axis=-1).astype(np.int64)
|
||||
inputs = self._textual.get_inputs()
|
||||
out = self._textual.run(None, {
|
||||
inputs[0].name: tokens,
|
||||
inputs[1].name: eot_indices,
|
||||
})[0][0]
|
||||
out = out / np.linalg.norm(out)
|
||||
return out.astype(np.float32)
|
||||
|
||||
@property
|
||||
def dim(self) -> int:
|
||||
return 512
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
Export / download all vision model weights to ONNX format.
|
||||
|
||||
Run ONCE on any machine with Python + pip (doesn't need GPU):
|
||||
|
||||
pip install open-clip-torch ultralytics onnx
|
||||
python -m app.services.vision.export_models [--models-dir /data/models]
|
||||
|
||||
This produces:
|
||||
embed/visual.onnx (~350 MB)
|
||||
embed/textual.onnx (~250 MB)
|
||||
detect/yolov8n.onnx (~12 MB)
|
||||
|
||||
YuNet and SFace are downloaded by bootstrap_models.py at worker boot
|
||||
(Apache 2.0, lightweight, no export step needed).
|
||||
|
||||
After export, copy the /data/models directory into your Docker volume:
|
||||
docker cp /data/models mulita-worker:/data/models
|
||||
Or mount a host path in docker-compose.yml.
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def export_openclip(models_dir: Path):
|
||||
"""Export OpenCLIP ViT-B/32 to two ONNX files (visual + textual)."""
|
||||
import torch
|
||||
import open_clip
|
||||
|
||||
out_dir = models_dir / "embed"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
visual_path = out_dir / "visual.onnx"
|
||||
textual_path = out_dir / "textual.onnx"
|
||||
|
||||
if visual_path.exists() and textual_path.exists():
|
||||
logger.info("OpenCLIP ONNX files already exist, skipping export")
|
||||
return
|
||||
|
||||
logger.info("Loading OpenCLIP ViT-B-32 laion2b_s34b_b79k...")
|
||||
model, _, preprocess = open_clip.create_model_and_transforms(
|
||||
"ViT-B-32", pretrained="laion2b_s34b_b79k"
|
||||
)
|
||||
model.eval()
|
||||
|
||||
# Use dynamo=False to get the legacy TorchScript exporter which
|
||||
# produces IR version 9 (compatible with onnxruntime 1.17.x).
|
||||
# The new torch.onnx.export default (dynamo=True) emits IR 10.
|
||||
export_kwargs = dict(opset_version=14, dynamo=False)
|
||||
|
||||
# ── Visual encoder ────────────────────────────────────────────────
|
||||
if not visual_path.exists():
|
||||
logger.info("Exporting visual encoder → %s", visual_path)
|
||||
dummy_image = torch.randn(1, 3, 224, 224)
|
||||
torch.onnx.export(
|
||||
model.visual,
|
||||
dummy_image,
|
||||
str(visual_path),
|
||||
input_names=["image"],
|
||||
output_names=["embedding"],
|
||||
dynamic_axes={"image": {0: "batch"}},
|
||||
**export_kwargs,
|
||||
)
|
||||
size_mb = visual_path.stat().st_size / 1e6
|
||||
logger.info("Visual encoder exported (%.1f MB)", size_mb)
|
||||
|
||||
# ── Textual encoder ───────────────────────────────────────────────
|
||||
if not textual_path.exists():
|
||||
logger.info("Exporting textual encoder → %s", textual_path)
|
||||
tokenizer = open_clip.get_tokenizer("ViT-B-32")
|
||||
dummy_text = tokenizer(["a photo"]).to(torch.int64)
|
||||
|
||||
class TextEncoder(torch.nn.Module):
|
||||
"""Wrap the CLIP text encoder to avoid argmax in the ONNX graph.
|
||||
OpenCLIP uses argmax to find the EOT token position, but ORT
|
||||
ARM64 doesn't support ArgMax(13). We pre-compute the EOT index
|
||||
from the token sequence and pass it directly."""
|
||||
def __init__(self, clip_model):
|
||||
super().__init__()
|
||||
self.transformer = clip_model.transformer
|
||||
self.token_embedding = clip_model.token_embedding
|
||||
self.positional_embedding = clip_model.positional_embedding
|
||||
self.ln_final = clip_model.ln_final
|
||||
self.text_projection = clip_model.text_projection
|
||||
|
||||
def forward(self, text, eot_indices):
|
||||
x = self.token_embedding(text)
|
||||
x = x + self.positional_embedding
|
||||
x = x.permute(1, 0, 2) # NLD -> LND
|
||||
x = self.transformer(x)
|
||||
x = x.permute(1, 0, 2) # LND -> NLD
|
||||
x = self.ln_final(x)
|
||||
# Take the feature at the EOT token. The EOT index is
|
||||
# passed in as a separate input (computed outside ONNX)
|
||||
# to avoid ArgMax(13) which ORT ARM64 doesn't support.
|
||||
x = x[torch.arange(x.shape[0]), eot_indices]
|
||||
x = x @ self.text_projection
|
||||
return x
|
||||
|
||||
text_enc = TextEncoder(model)
|
||||
text_enc.eval()
|
||||
|
||||
# Compute EOT indices from dummy tokens (argmax of token ids)
|
||||
dummy_eot = dummy_text.argmax(dim=-1)
|
||||
|
||||
torch.onnx.export(
|
||||
text_enc,
|
||||
(dummy_text, dummy_eot),
|
||||
str(textual_path),
|
||||
input_names=["text", "eot_indices"],
|
||||
output_names=["embedding"],
|
||||
dynamic_axes={"text": {0: "batch"}, "eot_indices": {0: "batch"}},
|
||||
**export_kwargs,
|
||||
)
|
||||
size_mb = textual_path.stat().st_size / 1e6
|
||||
logger.info("Textual encoder exported (%.1f MB)", size_mb)
|
||||
|
||||
|
||||
def export_yolov8n(models_dir: Path):
|
||||
"""Export YOLOv8n to ONNX."""
|
||||
out_dir = models_dir / "detect"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
onnx_path = out_dir / "yolov8n.onnx"
|
||||
|
||||
if onnx_path.exists():
|
||||
logger.info("YOLOv8n ONNX already exists, skipping export")
|
||||
return
|
||||
|
||||
logger.info("Exporting YOLOv8n → %s", onnx_path)
|
||||
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8n.pt")
|
||||
model.export(format="onnx", imgsz=640, simplify=True)
|
||||
|
||||
# ultralytics exports to cwd as yolov8n.onnx — move to target
|
||||
exported = Path("yolov8n.onnx")
|
||||
if exported.exists():
|
||||
exported.rename(onnx_path)
|
||||
|
||||
size_mb = onnx_path.stat().st_size / 1e6
|
||||
logger.info("YOLOv8n exported (%.1f MB)", size_mb)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Export vision model weights to ONNX")
|
||||
parser.add_argument(
|
||||
"--models-dir",
|
||||
type=Path,
|
||||
default=Path("/data/models"),
|
||||
help="Directory to write model files (default: /data/models)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
models_dir = args.models_dir
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("Exporting models to %s", models_dir)
|
||||
|
||||
export_openclip(models_dir)
|
||||
export_yolov8n(models_dir)
|
||||
|
||||
logger.info("Done. Run bootstrap_models.py next to download YuNet + SFace.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,148 +0,0 @@
|
||||
"""
|
||||
Face detection (YuNet) + recognition (SFace) using OpenCV DNN.
|
||||
|
||||
YuNet is loaded via cv2.FaceDetectorYN which handles the multi-scale
|
||||
anchor decoding and NMS internally. SFace recognition uses raw ONNX
|
||||
Runtime for the 128-d embedding.
|
||||
|
||||
Both models are from opencv_zoo (Apache 2.0 license).
|
||||
Expects {models_dir}/face/:
|
||||
- yunet.onnx (~233 KB)
|
||||
- sface.onnx (~37 MB, 128-d embeddings)
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
import onnxruntime as ort
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import FaceProcessor, FaceDetection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _align_face(image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
|
||||
"""Align and crop a 112x112 face patch using 5-point landmarks."""
|
||||
left_eye = landmarks[0]
|
||||
right_eye = landmarks[1]
|
||||
|
||||
dx = right_eye[0] - left_eye[0]
|
||||
dy = right_eye[1] - left_eye[1]
|
||||
angle = np.degrees(np.arctan2(dy, dx))
|
||||
eye_center = ((left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2)
|
||||
eye_dist = np.sqrt(dx * dx + dy * dy)
|
||||
|
||||
M = cv2.getRotationMatrix2D(eye_center, angle, 1.0)
|
||||
rotated = cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
|
||||
|
||||
# Crop around face center
|
||||
scale = 64.0 / max(eye_dist, 1e-6)
|
||||
cx, cy = eye_center
|
||||
half = 56.0 / scale
|
||||
x1 = max(0, int(cx - half))
|
||||
y1 = max(0, int(cy - half * 0.8))
|
||||
x2 = min(rotated.shape[1], int(cx + half))
|
||||
y2 = min(rotated.shape[0], int(cy + half * 1.2))
|
||||
crop = rotated[y1:y2, x1:x2]
|
||||
|
||||
if crop.size == 0:
|
||||
return np.zeros((112, 112, 3), dtype=np.float32)
|
||||
|
||||
return cv2.resize(crop, (112, 112)).astype(np.float32)
|
||||
|
||||
|
||||
class YuNetSFaceProcessor(FaceProcessor):
|
||||
def __init__(self, settings: VisionSettings):
|
||||
face_dir = Path(settings.models_dir) / "face"
|
||||
yunet_path = str(face_dir / "yunet.onnx")
|
||||
sface_path = str(face_dir / "sface.onnx")
|
||||
|
||||
# YuNet via OpenCV's FaceDetectorYN — handles anchor decoding + NMS
|
||||
self._detector = cv2.FaceDetectorYN.create(
|
||||
yunet_path,
|
||||
"",
|
||||
(640, 640),
|
||||
settings.faces.recognition_threshold,
|
||||
0.3, # NMS threshold
|
||||
5000, # top_k
|
||||
)
|
||||
logger.info("YuNet face detector loaded via OpenCV")
|
||||
|
||||
# SFace via ONNX Runtime
|
||||
opts = ort.SessionOptions()
|
||||
opts.inter_op_num_threads = 2
|
||||
opts.intra_op_num_threads = 2
|
||||
ort.set_default_logger_severity(3)
|
||||
self._recognizer = ort.InferenceSession(sface_path, opts, providers=["CPUExecutionProvider"])
|
||||
logger.info("SFace recognizer loaded via ONNX Runtime")
|
||||
|
||||
self._min_face_size = settings.faces.min_face_size
|
||||
|
||||
def process(self, image: np.ndarray) -> list[FaceDetection]:
|
||||
orig_h, orig_w = image.shape[:2]
|
||||
|
||||
# Convert RGB → BGR for OpenCV
|
||||
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||
|
||||
# Set input size to actual image dimensions
|
||||
self._detector.setInputSize((orig_w, orig_h))
|
||||
|
||||
# Detect faces
|
||||
_, faces_raw = self._detector.detect(bgr)
|
||||
|
||||
if faces_raw is None or len(faces_raw) == 0:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for face in faces_raw:
|
||||
# face: [x, y, w, h, right_eye_x, right_eye_y, left_eye_x, left_eye_y,
|
||||
# nose_x, nose_y, right_mouth_x, right_mouth_y, left_mouth_x, left_mouth_y, score]
|
||||
x, y, w, h = int(face[0]), int(face[1]), int(face[2]), int(face[3])
|
||||
score = float(face[14])
|
||||
|
||||
# Filter small faces
|
||||
face_size = max(w, h)
|
||||
if face_size < self._min_face_size:
|
||||
continue
|
||||
|
||||
# Normalized bbox
|
||||
bbox = [
|
||||
max(0, x / orig_w),
|
||||
max(0, y / orig_h),
|
||||
min(1, (x + w) / orig_w),
|
||||
min(1, (y + h) / orig_h),
|
||||
]
|
||||
|
||||
# Extract 5-point landmarks for alignment
|
||||
landmarks = np.array([
|
||||
[face[4], face[5]], # right eye
|
||||
[face[6], face[7]], # left eye
|
||||
[face[8], face[9]], # nose
|
||||
[face[10], face[11]], # right mouth
|
||||
[face[12], face[13]], # left mouth
|
||||
], dtype=np.float32)
|
||||
|
||||
# Align face for recognition
|
||||
face_crop = _align_face(image, landmarks)
|
||||
|
||||
# SFace expects (1, 3, 112, 112) float32, BGR
|
||||
face_bgr = cv2.cvtColor(face_crop.astype(np.uint8), cv2.COLOR_RGB2BGR)
|
||||
face_blob = (face_bgr.astype(np.float32) / 255.0).transpose(2, 0, 1)[np.newaxis]
|
||||
|
||||
rec_input = self._recognizer.get_inputs()[0].name
|
||||
embedding = self._recognizer.run(None, {rec_input: face_blob})[0][0]
|
||||
embedding = embedding / np.linalg.norm(embedding)
|
||||
|
||||
results.append(FaceDetection(
|
||||
bbox=bbox,
|
||||
embedding=embedding.astype(np.float32),
|
||||
quality=score,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
@property
|
||||
def embedding_dim(self) -> int:
|
||||
return 128
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Face detection + recognition using InsightFace (RetinaFace + ArcFace).
|
||||
|
||||
Uses the buffalo_l model pack which auto-downloads on first use (~300MB).
|
||||
Produces 512-d ArcFace embeddings. Non-commercial research license —
|
||||
fine for homelab self-hosting.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import FaceProcessor, FaceDetection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InsightFaceProcessor(FaceProcessor):
|
||||
def __init__(self, settings: VisionSettings):
|
||||
from insightface.app import FaceAnalysis
|
||||
|
||||
model_root = str(Path(settings.models_dir) / "face" / "insightface")
|
||||
logger.info("Loading InsightFace buffalo_l from %s", model_root)
|
||||
|
||||
self._app = FaceAnalysis(
|
||||
name="buffalo_l",
|
||||
root=model_root,
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
self._app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||
self._min_det_score = settings.faces.recognition_threshold
|
||||
|
||||
def process(self, image: np.ndarray) -> list[FaceDetection]:
|
||||
orig_h, orig_w = image.shape[:2]
|
||||
|
||||
# InsightFace expects BGR
|
||||
bgr = image[:, :, ::-1].copy()
|
||||
|
||||
faces = self._app.get(bgr)
|
||||
|
||||
if not faces:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for face in faces:
|
||||
if face.det_score < self._min_det_score:
|
||||
continue
|
||||
|
||||
# face.bbox is [x1, y1, x2, y2] in pixel coords
|
||||
x1, y1, x2, y2 = face.bbox
|
||||
bbox = [
|
||||
max(0, float(x1) / orig_w),
|
||||
max(0, float(y1) / orig_h),
|
||||
min(1, float(x2) / orig_w),
|
||||
min(1, float(y2) / orig_h),
|
||||
]
|
||||
|
||||
embedding = face.normed_embedding # already L2-normalized, 512-d
|
||||
results.append(FaceDetection(
|
||||
bbox=bbox,
|
||||
embedding=embedding.astype(np.float32),
|
||||
quality=float(face.det_score),
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
@property
|
||||
def embedding_dim(self) -> int:
|
||||
return 512
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
OCR engine using rapidocr-onnxruntime (PP-OCRv4 weights).
|
||||
|
||||
No PaddlePaddle dependency — pure ONNX Runtime. Language packs are
|
||||
downloaded automatically by rapidocr on first use.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import OCREngine, OCRResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RapidOCREngine(OCREngine):
|
||||
def __init__(self, settings: VisionSettings):
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
self._min_confidence = settings.ocr.min_confidence
|
||||
self._engine = RapidOCR()
|
||||
logger.info("RapidOCR engine initialized")
|
||||
|
||||
def run(self, image: np.ndarray) -> list[OCRResult]:
|
||||
result, _ = self._engine(image)
|
||||
if not result:
|
||||
return []
|
||||
|
||||
out = []
|
||||
for box, text, score in result:
|
||||
if score < self._min_confidence:
|
||||
continue
|
||||
# box is [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] — take bounding rect
|
||||
xs = [p[0] for p in box]
|
||||
ys = [p[1] for p in box]
|
||||
h, w = image.shape[:2]
|
||||
bbox = [
|
||||
min(xs) / w,
|
||||
min(ys) / h,
|
||||
max(xs) / w,
|
||||
max(ys) / h,
|
||||
]
|
||||
out.append(OCRResult(text=text, confidence=float(score), bbox=bbox))
|
||||
return out
|
||||
@@ -1,41 +0,0 @@
|
||||
"""
|
||||
ONNX Runtime backend — default CPU inference for all vision models.
|
||||
|
||||
Each create_* method returns a concrete implementation of the
|
||||
corresponding ABC from base.py. Models are loaded from ONNX files
|
||||
under settings.vision.models_dir, downloaded on first boot by
|
||||
bootstrap_models.py.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ONNXBackend:
|
||||
"""Factory for ONNX Runtime-based vision model instances."""
|
||||
|
||||
def __init__(self, vision_settings: VisionSettings):
|
||||
self._settings = vision_settings
|
||||
|
||||
def create_embedder(self) -> Embedder:
|
||||
from app.services.vision.embed import OpenCLIPEmbedder
|
||||
return OpenCLIPEmbedder(self._settings)
|
||||
|
||||
def create_ocr(self) -> OCREngine:
|
||||
from app.services.vision.ocr import RapidOCREngine
|
||||
return RapidOCREngine(self._settings)
|
||||
|
||||
def create_detector(self) -> ObjectDetector:
|
||||
from app.services.vision.detect import YOLOv8Detector
|
||||
return YOLOv8Detector(self._settings)
|
||||
|
||||
def create_face_processor(self) -> FaceProcessor:
|
||||
from app.services.vision.insightface_processor import InsightFaceProcessor
|
||||
return InsightFaceProcessor(self._settings)
|
||||
|
||||
def create_classifier(self) -> ContentClassifier:
|
||||
from app.services.vision.classify import CLIPContentClassifier
|
||||
return CLIPContentClassifier(self._settings)
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
ModelRegistry — singleton that lazy-loads vision models per worker process.
|
||||
|
||||
Usage from Celery tasks:
|
||||
|
||||
from app.services.vision.registry import registry
|
||||
embedder = registry.get_embedder()
|
||||
vec = embedder.embed_image(img)
|
||||
|
||||
Models are created on first access and cached for the worker's lifetime.
|
||||
The registry reads settings.vision to decide which backend to use and
|
||||
where model weights live.
|
||||
"""
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from app.config import settings
|
||||
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""Central access point for all vision models."""
|
||||
|
||||
def __init__(self):
|
||||
self._vision = settings.vision
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_embedder(self) -> Embedder:
|
||||
logger.info("Loading embedder: %s (backend=%s)", self._vision.embedder.name, self._vision.backend)
|
||||
return self._load_backend().create_embedder()
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_ocr(self) -> OCREngine:
|
||||
logger.info("Loading OCR engine (backend=%s)", self._vision.backend)
|
||||
return self._load_backend().create_ocr()
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_detector(self) -> ObjectDetector:
|
||||
logger.info("Loading object detector (backend=%s)", self._vision.backend)
|
||||
return self._load_backend().create_detector()
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_face_processor(self) -> FaceProcessor:
|
||||
logger.info("Loading face processor (backend=%s)", self._vision.backend)
|
||||
return self._load_backend().create_face_processor()
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_classifier(self) -> ContentClassifier:
|
||||
logger.info("Loading content classifier (backend=%s)", self._vision.backend)
|
||||
return self._load_backend().create_classifier()
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_backend(self):
|
||||
"""Import and instantiate the configured backend."""
|
||||
backend_name = self._vision.backend
|
||||
if backend_name == "onnx":
|
||||
from app.services.vision.onnx_backend import ONNXBackend
|
||||
return ONNXBackend(self._vision)
|
||||
elif backend_name == "rocm":
|
||||
from app.services.vision.rocm_backend import ROCmBackend
|
||||
return ROCmBackend(self._vision)
|
||||
else:
|
||||
raise ValueError(f"Unknown vision backend: {backend_name}")
|
||||
|
||||
def warmup(self):
|
||||
"""Pre-load all enabled models. Called from Celery worker_process_init
|
||||
on the vision queue to avoid cold-start latency on the first task."""
|
||||
logger.info("Warming up vision models...")
|
||||
self.get_embedder()
|
||||
if self._vision.ocr.enabled:
|
||||
self.get_ocr()
|
||||
if self._vision.detector.enabled:
|
||||
self.get_detector()
|
||||
if self._vision.faces.enabled:
|
||||
self.get_face_processor()
|
||||
if self._vision.classifier.enabled:
|
||||
self.get_classifier()
|
||||
logger.info("Vision model warmup complete")
|
||||
|
||||
|
||||
# Module-level singleton. Import this from tasks.
|
||||
registry = ModelRegistry()
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
ROCm backend — GPU-accelerated inference for Radeon 760M-class hardware.
|
||||
|
||||
Stub: raises NotImplementedError on all factory methods. To enable,
|
||||
set `vision.backend: rocm` in mulita.yml once ROCm support is implemented.
|
||||
"""
|
||||
from app.config import VisionSettings
|
||||
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor
|
||||
|
||||
|
||||
class ROCmBackend:
|
||||
def __init__(self, vision_settings: VisionSettings):
|
||||
self._settings = vision_settings
|
||||
|
||||
def create_embedder(self) -> Embedder:
|
||||
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
|
||||
|
||||
def create_ocr(self) -> OCREngine:
|
||||
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
|
||||
|
||||
def create_detector(self) -> ObjectDetector:
|
||||
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
|
||||
|
||||
def create_face_processor(self) -> FaceProcessor:
|
||||
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
|
||||
@@ -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,38 +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', 'app.tasks.vision']
|
||||
)
|
||||
|
||||
# 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'},
|
||||
'app.tasks.vision.*': {'queue': 'vision'},
|
||||
'embed_photo': {'queue': 'vision'},
|
||||
'ocr_photo': {'queue': 'vision'},
|
||||
'detect_objects': {'queue': 'vision'},
|
||||
'extract_faces': {'queue': 'vision'},
|
||||
'classify_content': {'queue': 'vision'},
|
||||
'vision_fanout': {'queue': 'vision'},
|
||||
},
|
||||
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,499 +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, func
|
||||
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. We only care
|
||||
# whether *any* other photo shares this hash, so
|
||||
# use a count rather than scalar_one_or_none()
|
||||
# which raises "Multiple rows were found" the
|
||||
# moment the library has 2+ copies of the same
|
||||
# file (i.e. exactly the case we're trying to
|
||||
# flag).
|
||||
is_dup = False
|
||||
if file_hash:
|
||||
dup_count = (await session.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.file_hash == file_hash
|
||||
)
|
||||
)).scalar() or 0
|
||||
is_dup = dup_count > 0
|
||||
|
||||
# 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=is_dup,
|
||||
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).
|
||||
|
||||
After dispatching the scans, queue a delayed `regroup_duplicates`
|
||||
pass so duplicate clusters are recomputed once the new photos have
|
||||
finished thumbnailing (and therefore picked up phashes). The
|
||||
countdown is a best-effort hint — on a big library the user can
|
||||
still hit Settings → Re-detect duplicates to force a fresh pass.
|
||||
"""
|
||||
from app.tasks.thumbs import regroup_duplicates_task
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
)
|
||||
source_roots = result.scalars().all()
|
||||
dispatched = 0
|
||||
for sr in source_roots:
|
||||
if os.path.exists(sr.path):
|
||||
scan_folder.delay(sr.path, sr.id)
|
||||
dispatched += 1
|
||||
else:
|
||||
logger.warning(f"Source root path does not exist: {sr.path}")
|
||||
|
||||
if dispatched > 0:
|
||||
# 60s gives the thumbs worker a window to compute phashes for
|
||||
# the new photos before regrouping. The task is idempotent, so
|
||||
# firing too early just means the next manual run picks up the
|
||||
# late arrivals — no corrupted state.
|
||||
try:
|
||||
regroup_duplicates_task.apply_async(countdown=60)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue post-scan regroup: {e}")
|
||||
|
||||
|
||||
@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}")
|
||||
|
||||
|
||||
@shared_task(name='backfill_gps')
|
||||
def backfill_gps():
|
||||
"""Re-run metadata extraction on every non-discarded photo that is
|
||||
missing latitude/longitude. Used both as a one-shot kick-off after the
|
||||
GPS columns are added on an existing install (see app/database.py) and
|
||||
as a manual trigger from POST /api/v1/library/backfill-gps. Each
|
||||
extract_metadata call is itself a Celery task, so this just enqueues —
|
||||
it does not block on extraction completing."""
|
||||
return asyncio.run(_backfill_gps_async())
|
||||
|
||||
|
||||
async def _backfill_gps_async():
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Photo.id).where(
|
||||
Photo.latitude.is_(None),
|
||||
Photo.is_discarded.is_(False),
|
||||
)
|
||||
)
|
||||
photo_ids = [row[0] for row in result.all()]
|
||||
|
||||
for pid in photo_ids:
|
||||
extract_metadata.delay(pid)
|
||||
|
||||
logger.info(f"backfill_gps: queued extract_metadata for {len(photo_ids)} photos")
|
||||
return {'queued': len(photo_ids)}
|
||||
@@ -1,436 +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 a still frame from a video file as a PIL Image."""
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
# Find a usable seek timestamp. Some camera MOVs only expose
|
||||
# duration at the format level, and stream 0 isn't always the
|
||||
# video stream — search explicitly and fall back to the format
|
||||
# duration, then to t=0 if neither is available.
|
||||
probe = ffmpeg.probe(filepath)
|
||||
duration: Optional[float] = None
|
||||
for stream_info in probe.get('streams', []):
|
||||
if stream_info.get('codec_type') != 'video':
|
||||
continue
|
||||
raw_duration = stream_info.get('duration')
|
||||
if raw_duration is not None:
|
||||
try:
|
||||
duration = float(raw_duration)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if duration is None:
|
||||
raw_duration = probe.get('format', {}).get('duration')
|
||||
if raw_duration is not None:
|
||||
try:
|
||||
duration = float(raw_duration)
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
|
||||
# Seek to 10% in for a representative frame; clamp very short
|
||||
# clips to t=0 so we don't seek past the end.
|
||||
timestamp = max(0.0, (duration or 0.0) * 0.1)
|
||||
|
||||
# NamedTemporaryFile creates the file on disk, so we MUST tell
|
||||
# ffmpeg to overwrite it (otherwise it prompts on stdin and the
|
||||
# call hangs/fails — which is why videos were getting the gray
|
||||
# placeholder). We close the handle immediately and clean up
|
||||
# in `finally` ourselves.
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
stream = ffmpeg.input(filepath, ss=timestamp)
|
||||
stream = ffmpeg.output(
|
||||
stream,
|
||||
tmp_path,
|
||||
vframes=1,
|
||||
format='image2',
|
||||
vcodec='mjpeg',
|
||||
)
|
||||
ffmpeg.run(
|
||||
stream,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
overwrite_output=True,
|
||||
)
|
||||
|
||||
# Load the frame fully into memory so we can delete the temp
|
||||
# file immediately. Pillow's `Image.open` is lazy, which would
|
||||
# otherwise leave the file dangling.
|
||||
with open(tmp_path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
if not data:
|
||||
raise RuntimeError("ffmpeg produced an empty frame")
|
||||
return Image.open(BytesIO(data)).copy()
|
||||
except ffmpeg.Error as e:
|
||||
stderr = (e.stderr or b'').decode('utf-8', errors='replace')
|
||||
logger.error(
|
||||
f"ffmpeg failed extracting video thumbnail from {filepath}: {stderr}"
|
||||
)
|
||||
return create_placeholder_thumbnail('video')
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
|
||||
return create_placeholder_thumbnail('video')
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
# Perceptual hash from the original-resolution decoded frame.
|
||||
# pHash is robust to resize/recompression but the thumbnail
|
||||
# loop below mutates `image` in place, so this MUST run before
|
||||
# the loop sees it. Failures are non-fatal — phash is a
|
||||
# nice-to-have, not a blocker for thumbnail generation.
|
||||
try:
|
||||
import imagehash
|
||||
photo.phash = str(imagehash.phash(image)) # 16-char hex
|
||||
except Exception as e:
|
||||
logger.warning(f"phash failed for {photo_id}: {e}")
|
||||
photo.phash = None
|
||||
|
||||
# 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}")
|
||||
|
||||
# Dispatch vision pipeline (embedding, OCR, detection, faces)
|
||||
# after thumbs are ready so vision tasks have images to read.
|
||||
try:
|
||||
from app.tasks.vision import vision_fanout
|
||||
vision_fanout.delay(photo_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not dispatch vision_fanout for {photo_id}: {e}")
|
||||
|
||||
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)}
|
||||
|
||||
|
||||
# ── Perceptual hash backfill ────────────────────────────────────────────
|
||||
#
|
||||
# When phash was added post-launch, every existing photo has phash=NULL.
|
||||
# This task fills them in by reading the existing thumb_large (the cheap
|
||||
# option — pHash is robust to scale, and the thumb is already on local
|
||||
# disk so we avoid re-decoding the original RAW/HEIC). Falls back to the
|
||||
# original filepath if the thumb isn't available for some reason. Runs
|
||||
# in batches to keep memory bounded and to give the user incremental
|
||||
# progress visible in the worker logs.
|
||||
|
||||
@shared_task(name='backfill_phashes')
|
||||
def backfill_phashes():
|
||||
"""Compute and persist phash for every photo currently missing one."""
|
||||
return asyncio.run(_backfill_phashes_async())
|
||||
|
||||
|
||||
async def _backfill_phashes_async():
|
||||
import imagehash
|
||||
from PIL import Image as _PILImage
|
||||
|
||||
BATCH = 100
|
||||
total_done = 0
|
||||
total_failed = 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
while True:
|
||||
result = await session.execute(
|
||||
select(Photo)
|
||||
.where(Photo.phash.is_(None))
|
||||
.where(Photo.processing_status == 'completed')
|
||||
.limit(BATCH)
|
||||
)
|
||||
batch = result.scalars().all()
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for photo in batch:
|
||||
source = photo.thumb_large or photo.filepath
|
||||
try:
|
||||
if not source or not os.path.exists(source):
|
||||
photo.phash = None
|
||||
total_failed += 1
|
||||
continue
|
||||
with _PILImage.open(source) as im:
|
||||
photo.phash = str(imagehash.phash(im))
|
||||
total_done += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"phash backfill failed for {photo.id}: {e}")
|
||||
total_failed += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Backfilled phashes: {total_done} done, {total_failed} failed"
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'computed': total_done,
|
||||
'failed': total_failed,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name='regroup_duplicates')
|
||||
def regroup_duplicates_task():
|
||||
"""Celery wrapper around app.services.duplicates.regroup_duplicates.
|
||||
|
||||
Importing the service inside the task body avoids a circular import
|
||||
at worker boot (the service uses AsyncSessionLocal which is also
|
||||
imported here at module top)."""
|
||||
from app.services.duplicates import regroup_duplicates
|
||||
return asyncio.run(regroup_duplicates())
|
||||
@@ -1,479 +0,0 @@
|
||||
"""
|
||||
Celery tasks for the vision pipeline — embedding, OCR, object detection,
|
||||
face recognition.
|
||||
|
||||
All tasks run on the dedicated `vision` queue with limited concurrency
|
||||
(memory-bound CPU inference). They read thumbnails generated by
|
||||
generate_thumbnails, so they MUST run after thumbs complete.
|
||||
|
||||
DB access uses sync psycopg2 sessions (not asyncpg) because Celery
|
||||
forks workers and asyncpg connections can't be shared across forks.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from celery import shared_task
|
||||
from sqlalchemy import create_engine, text as sa_text, select, delete
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from PIL import Image
|
||||
|
||||
from app.models.embeddings import Embedding
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_sync_session() -> Session:
|
||||
"""Create a sync DB session for use in Celery workers."""
|
||||
sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "")
|
||||
engine = create_engine(sync_url, pool_pre_ping=True)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
|
||||
"""Load a thumbnail as an RGB numpy array."""
|
||||
thumb_path = Path(f"/data/thumbs/{photo_id}/{size}.webp")
|
||||
if not thumb_path.exists():
|
||||
logger.warning("Thumbnail not found: %s", thumb_path)
|
||||
return None
|
||||
img = Image.open(thumb_path).convert("RGB")
|
||||
return np.array(img)
|
||||
|
||||
|
||||
@shared_task(name='embed_photo', queue='vision')
|
||||
def embed_photo(photo_id: str):
|
||||
"""Generate CLIP embedding for a photo and store in pgvector."""
|
||||
if not settings.vision.enabled:
|
||||
return {'status': 'skipped', 'reason': 'vision disabled'}
|
||||
|
||||
image = _load_thumb(photo_id, "medium") # 640px
|
||||
if image is None:
|
||||
return {'status': 'error', 'message': 'thumbnail not found'}
|
||||
|
||||
from app.services.vision.registry import registry
|
||||
embedder = registry.get_embedder()
|
||||
vector = embedder.embed_image(image)
|
||||
|
||||
model_name = settings.vision.embedder.name
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
session.execute(
|
||||
delete(Embedding).where(
|
||||
Embedding.photo_id == photo_id,
|
||||
Embedding.model == model_name,
|
||||
)
|
||||
)
|
||||
emb = Embedding(
|
||||
photo_id=photo_id,
|
||||
model=model_name,
|
||||
vector=vector.tolist(),
|
||||
)
|
||||
session.add(emb)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
logger.info("Embedded photo %s with %s", photo_id, model_name)
|
||||
return {'status': 'success', 'photo_id': photo_id}
|
||||
|
||||
|
||||
@shared_task(name='vision_fanout', queue='vision')
|
||||
def vision_fanout(photo_id: str):
|
||||
"""Dispatch all enabled vision tasks for a photo."""
|
||||
if not settings.vision.enabled:
|
||||
return {'status': 'skipped', 'reason': 'vision disabled'}
|
||||
|
||||
embed_photo.delay(photo_id)
|
||||
|
||||
if settings.vision.ocr.enabled:
|
||||
ocr_photo.delay(photo_id)
|
||||
if settings.vision.detector.enabled:
|
||||
detect_objects.delay(photo_id)
|
||||
if settings.vision.faces.enabled:
|
||||
extract_faces.delay(photo_id)
|
||||
if settings.vision.classifier.enabled:
|
||||
classify_content.delay(photo_id)
|
||||
|
||||
return {'status': 'dispatched', 'photo_id': photo_id}
|
||||
|
||||
|
||||
@shared_task(name='ocr_photo', queue='vision')
|
||||
def ocr_photo(photo_id: str):
|
||||
"""Run OCR on a photo and store text regions."""
|
||||
if not settings.vision.enabled or not settings.vision.ocr.enabled:
|
||||
return {'status': 'skipped', 'reason': 'OCR disabled'}
|
||||
|
||||
image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy
|
||||
if image is None:
|
||||
return {'status': 'error', 'message': 'thumbnail not found'}
|
||||
|
||||
from app.services.vision.registry import registry
|
||||
ocr_engine = registry.get_ocr()
|
||||
results = ocr_engine.run(image)
|
||||
|
||||
if not results:
|
||||
logger.info("No OCR text found for photo %s", photo_id)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'regions': 0}
|
||||
|
||||
from app.models.ocr_text import OCRText
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
session.execute(delete(OCRText).where(OCRText.photo_id == photo_id))
|
||||
for r in results:
|
||||
session.add(OCRText(
|
||||
photo_id=photo_id,
|
||||
text=r.text,
|
||||
language=r.language,
|
||||
confidence=r.confidence,
|
||||
bbox=r.bbox,
|
||||
))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
logger.info("OCR: %d text regions for photo %s", len(results), photo_id)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'regions': len(results)}
|
||||
|
||||
|
||||
@shared_task(name='detect_objects', queue='vision')
|
||||
def detect_objects(photo_id: str):
|
||||
"""Detect objects in a photo, create Tag(kind=object) rows, and
|
||||
link via photo_tags with confidence/bbox/source."""
|
||||
if not settings.vision.enabled or not settings.vision.detector.enabled:
|
||||
return {'status': 'skipped', 'reason': 'detection disabled'}
|
||||
|
||||
image = _load_thumb(photo_id, "medium") # 640px
|
||||
if image is None:
|
||||
return {'status': 'error', 'message': 'thumbnail not found'}
|
||||
|
||||
from app.services.vision.registry import registry
|
||||
detector = registry.get_detector()
|
||||
detections = detector.detect(image)
|
||||
|
||||
if not detections:
|
||||
logger.info("No objects detected for photo %s", photo_id)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'objects': 0}
|
||||
|
||||
from app.models.tags import Tag, photo_tags
|
||||
|
||||
source_name = "vision:yolov8n"
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
# Wipe previous detection results for this photo from this model
|
||||
session.execute(
|
||||
delete(photo_tags).where(
|
||||
photo_tags.c.photo_id == photo_id,
|
||||
photo_tags.c.source == source_name,
|
||||
)
|
||||
)
|
||||
|
||||
# Group detections by label, keep highest confidence per label
|
||||
best_per_label: dict[str, tuple[float, list]] = {}
|
||||
for det in detections:
|
||||
if det.label not in best_per_label or det.confidence > best_per_label[det.label][0]:
|
||||
best_per_label[det.label] = (det.confidence, det.bbox)
|
||||
|
||||
for label, (confidence, bbox) in best_per_label.items():
|
||||
# Find or create the object tag
|
||||
tag = session.execute(
|
||||
select(Tag).where(Tag.name == label, Tag.kind == 'object')
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not tag:
|
||||
tag = Tag(name=label, kind='object', source=source_name)
|
||||
session.add(tag)
|
||||
session.flush() # get tag.id
|
||||
|
||||
# Insert photo_tags association with ML metadata
|
||||
session.execute(
|
||||
photo_tags.insert().values(
|
||||
photo_id=photo_id,
|
||||
tag_id=tag.id,
|
||||
confidence=confidence,
|
||||
bbox=bbox,
|
||||
source=source_name,
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
labels = [d.label for d in detections]
|
||||
logger.info("Detected %d objects in photo %s: %s", len(detections), photo_id, labels)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'objects': len(detections)}
|
||||
|
||||
|
||||
@shared_task(name='classify_content', queue='vision')
|
||||
def classify_content(photo_id: str):
|
||||
"""Classify image content type (screenshot, document, artwork, etc.)
|
||||
using CLIP zero-shot classification. Writes Tag(kind=content_type)."""
|
||||
if not settings.vision.enabled or not settings.vision.classifier.enabled:
|
||||
return {'status': 'skipped', 'reason': 'classifier disabled'}
|
||||
|
||||
image = _load_thumb(photo_id, "medium")
|
||||
if image is None:
|
||||
return {'status': 'error', 'message': 'thumbnail not found'}
|
||||
|
||||
from app.services.vision.registry import registry
|
||||
classifier = registry.get_classifier()
|
||||
results = classifier.classify(image)
|
||||
|
||||
if not results:
|
||||
logger.info("No confident classification for photo %s", photo_id)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'content_type': None}
|
||||
|
||||
from app.models.tags import Tag, photo_tags
|
||||
|
||||
source_name = "vision:clip_classifier"
|
||||
best = results[0]
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
# Wipe previous classification for this photo
|
||||
session.execute(
|
||||
delete(photo_tags).where(
|
||||
photo_tags.c.photo_id == photo_id,
|
||||
photo_tags.c.source == source_name,
|
||||
)
|
||||
)
|
||||
|
||||
# Find or create content_type tag
|
||||
tag = session.execute(
|
||||
select(Tag).where(Tag.name == best.label, Tag.kind == 'content_type')
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not tag:
|
||||
tag = Tag(name=best.label, kind='content_type', source=source_name)
|
||||
session.add(tag)
|
||||
session.flush()
|
||||
|
||||
session.execute(
|
||||
photo_tags.insert().values(
|
||||
photo_id=photo_id,
|
||||
tag_id=tag.id,
|
||||
confidence=best.confidence,
|
||||
source=source_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
logger.info("Classified photo %s as '%s' (%.2f)", photo_id, best.label, best.confidence)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'content_type': best.label}
|
||||
|
||||
|
||||
def _load_original(photo_id: str) -> np.ndarray | None:
|
||||
"""Load the original photo file as an RGB numpy array, resized to
|
||||
max 1280px on the longest edge for face detection."""
|
||||
from sqlalchemy import create_engine, select as sa_select, text as sa_text
|
||||
from app.models import Photo
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
photo = session.execute(
|
||||
sa_select(Photo).where(Photo.id == photo_id)
|
||||
).scalar_one_or_none()
|
||||
if not photo or not photo.filepath:
|
||||
return None
|
||||
filepath = photo.filepath
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
if not Path(filepath).exists():
|
||||
logger.warning("Original file not found: %s", filepath)
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(filepath).convert("RGB")
|
||||
# Cap at 4000px on longest edge to avoid OOM, but keep as large
|
||||
# as possible for face detection accuracy
|
||||
max_dim = 4000
|
||||
w, h = img.size
|
||||
if max(w, h) > max_dim:
|
||||
scale = max_dim / max(w, h)
|
||||
img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
|
||||
return np.array(img)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load original %s: %s", filepath, e)
|
||||
return None
|
||||
|
||||
|
||||
@shared_task(name='extract_faces', queue='vision')
|
||||
def extract_faces(photo_id: str):
|
||||
"""Detect faces and store recognition embeddings using InsightFace
|
||||
(RetinaFace + ArcFace). No YOLO workaround needed — RetinaFace has
|
||||
strong human-vs-non-human precision on its own."""
|
||||
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
||||
return {'status': 'skipped', 'reason': 'faces disabled'}
|
||||
|
||||
image = _load_original(photo_id)
|
||||
if image is None:
|
||||
image = _load_thumb(photo_id, "large")
|
||||
if image is None:
|
||||
return {'status': 'error', 'message': 'no image available'}
|
||||
|
||||
from app.services.vision.registry import registry
|
||||
face_proc = registry.get_face_processor()
|
||||
faces = face_proc.process(image)
|
||||
|
||||
if not faces:
|
||||
logger.info("No faces detected for photo %s", photo_id)
|
||||
|
||||
return _save_faces(photo_id, faces)
|
||||
|
||||
|
||||
def _save_faces(photo_id: str, faces) -> dict:
|
||||
from app.models.face_embedding import FaceEmbedding
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
session.execute(delete(FaceEmbedding).where(FaceEmbedding.photo_id == photo_id))
|
||||
for face in faces:
|
||||
session.add(FaceEmbedding(
|
||||
photo_id=photo_id,
|
||||
bbox=face.bbox,
|
||||
vector=face.embedding.tolist(),
|
||||
quality=face.quality,
|
||||
cluster_id=None,
|
||||
))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
if faces:
|
||||
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
|
||||
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
|
||||
|
||||
|
||||
@shared_task(name='recluster_faces', queue='vision')
|
||||
def recluster_faces():
|
||||
"""Run DBSCAN clustering over all face embeddings and assign/create
|
||||
Tag(kind=face_cluster) entries."""
|
||||
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
||||
return {'status': 'skipped', 'reason': 'faces disabled'}
|
||||
|
||||
from app.models.face_embedding import FaceEmbedding
|
||||
from app.models.tags import Tag, photo_tags
|
||||
from app.services.vision.clustering import cluster_faces
|
||||
|
||||
source_name = "vision:sface"
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
face_rows = session.execute(
|
||||
select(FaceEmbedding).order_by(FaceEmbedding.created_at)
|
||||
).scalars().all()
|
||||
|
||||
if len(face_rows) < 2:
|
||||
logger.info("Not enough faces for clustering (%d)", len(face_rows))
|
||||
return {'status': 'success', 'clusters': 0}
|
||||
|
||||
embeddings = np.array([f.vector for f in face_rows], dtype=np.float32)
|
||||
labels = cluster_faces(embeddings, eps=settings.vision.faces.cluster_eps)
|
||||
|
||||
# Clean up old face_cluster tags and their photo_tags
|
||||
old_cluster_tags = session.execute(
|
||||
select(Tag).where(Tag.kind == 'face_cluster', Tag.source == source_name)
|
||||
).scalars().all()
|
||||
for old_tag in old_cluster_tags:
|
||||
session.execute(
|
||||
delete(photo_tags).where(
|
||||
photo_tags.c.tag_id == old_tag.id,
|
||||
photo_tags.c.source == source_name,
|
||||
)
|
||||
)
|
||||
session.delete(old_tag)
|
||||
session.flush()
|
||||
|
||||
# Build new clusters
|
||||
cluster_tag_map: dict[int, str] = {}
|
||||
# Track which photos belong to which cluster
|
||||
cluster_photos: dict[int, set[str]] = {}
|
||||
|
||||
for i, label in enumerate(labels):
|
||||
if label == -1:
|
||||
face_rows[i].cluster_id = None
|
||||
continue
|
||||
|
||||
if label not in cluster_photos:
|
||||
cluster_photos[label] = set()
|
||||
cluster_photos[label].add(face_rows[i].photo_id)
|
||||
|
||||
if label not in cluster_tag_map:
|
||||
cluster_name = f"Person {label + 1}"
|
||||
tag = Tag(
|
||||
name=cluster_name,
|
||||
kind='face_cluster',
|
||||
source=source_name,
|
||||
representative_photo_id=face_rows[i].photo_id,
|
||||
)
|
||||
session.add(tag)
|
||||
session.flush()
|
||||
cluster_tag_map[label] = tag.id
|
||||
|
||||
face_rows[i].cluster_id = cluster_tag_map[label]
|
||||
|
||||
# Write photo_tags associations so the tag count and tag_ids
|
||||
# filter work for face clusters
|
||||
for label, photo_ids in cluster_photos.items():
|
||||
tag_id = cluster_tag_map[label]
|
||||
for pid in photo_ids:
|
||||
session.execute(
|
||||
photo_tags.insert().values(
|
||||
photo_id=pid,
|
||||
tag_id=tag_id,
|
||||
source=source_name,
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
n_clusters = len(cluster_tag_map)
|
||||
logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows))
|
||||
return {'status': 'success', 'clusters': n_clusters, 'faces': len(face_rows)}
|
||||
|
||||
|
||||
@shared_task(name='backfill_vision')
|
||||
def backfill_vision(task: str | None = None, limit: int | None = None):
|
||||
"""Queue vision tasks for photos that haven't been processed yet.
|
||||
Uses a sync DB connection to avoid asyncpg conflicts in Celery."""
|
||||
model_name = settings.vision.embedder.name
|
||||
sql = """
|
||||
SELECT p.id FROM photos p
|
||||
LEFT JOIN embeddings e ON e.photo_id = p.id AND e.model = :model
|
||||
WHERE e.photo_id IS NULL
|
||||
AND p.processing_status = 'completed'
|
||||
ORDER BY p.added_at DESC
|
||||
"""
|
||||
if limit:
|
||||
sql += f" LIMIT {limit}"
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
result = session.execute(sa_text(sql), {"model": model_name})
|
||||
photo_ids = [row[0] for row in result.fetchall()]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
count = 0
|
||||
for pid in photo_ids:
|
||||
if task == 'embed' or task is None:
|
||||
embed_photo.delay(pid)
|
||||
if task == 'ocr' or task is None:
|
||||
ocr_photo.delay(pid)
|
||||
if task == 'detect' or task is None:
|
||||
detect_objects.delay(pid)
|
||||
if task == 'faces' or task is None:
|
||||
extract_faces.delay(pid)
|
||||
count += 1
|
||||
|
||||
logger.info("Backfill queued %d photos for vision processing", count)
|
||||
return {'status': 'queued', 'count': count}
|
||||
@@ -1,61 +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 # SQLite escape hatch (docker-compose.sqlite.yml override)
|
||||
asyncpg==0.29.0 # async Postgres driver (default)
|
||||
psycopg2-binary==2.9.9 # sync Postgres driver, used by Alembic CLI
|
||||
pgvector==0.2.5 # pgvector SQLAlchemy types
|
||||
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
|
||||
imagehash==4.3.1 # perceptual hash for duplicate detection
|
||||
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
|
||||
|
||||
# Vision pipeline (ONNX Runtime CPU inference)
|
||||
onnxruntime==1.18.1
|
||||
open-clip-torch==2.24.0 # tokenizer + export helper; inference via ONNX
|
||||
rapidocr-onnxruntime==1.3.22
|
||||
scikit-learn==1.4.0 # DBSCAN for face clustering
|
||||
insightface>=0.7.3 # RetinaFace + ArcFace face detection/recognition
|
||||
numpy>=1.26.0,<2.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,47 +0,0 @@
|
||||
# SQLite escape hatch override.
|
||||
#
|
||||
# Usage (omit the `db` service from the up command):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.sqlite.yml \
|
||||
# up frontend backend worker redis
|
||||
#
|
||||
# This pins the backend and worker to the legacy SQLite database file at
|
||||
# /data/db/mulita.db (in the existing db_data volume), drops the dependency
|
||||
# on Postgres, and skips Alembic — the SQLite schema is still managed by
|
||||
# the inline ALTERs in app/database.py:init_db.
|
||||
#
|
||||
# Vision features that depend on pgvector (PR4 onward) will refuse to enable
|
||||
# in this mode; the search/embedding endpoints will return 503 with a clear
|
||||
# error pointing back at the default Postgres setup.
|
||||
|
||||
services:
|
||||
backend:
|
||||
command: sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
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:
|
||||
condition: service_started
|
||||
|
||||
worker:
|
||||
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:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_started
|
||||
@@ -1,135 +1,209 @@
|
||||
# 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
|
||||
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
|
||||
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:
|
||||
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:
|
||||
# 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
|
||||
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"
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
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 # retained so the docker-compose.sqlite.yml override has somewhere to put mulita.db
|
||||
# Run Alembic migrations before starting uvicorn. On a fresh Postgres
|
||||
# the empty 0001 baseline is a no-op stamp; create_all in init_db then
|
||||
# builds the schema.
|
||||
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||||
- 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:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mulita-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-worker
|
||||
command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4} -Q default,high,low,vision"
|
||||
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
|
||||
- models_data:/data/models
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||||
- 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:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mulita-network
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: mulita-db
|
||||
environment:
|
||||
POSTGRES_USER: mulita
|
||||
POSTGRES_PASSWORD: mulita
|
||||
POSTGRES_DB: mulita
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- mulita-network
|
||||
restart: unless-stopped
|
||||
- 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-SHELL", "pg_isready -U mulita -d mulita"]
|
||||
interval: 5s
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
networks: [photoprism-network]
|
||||
|
||||
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.
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- mulita-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:
|
||||
pg_data:
|
||||
models_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,54 +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;
|
||||
}
|
||||
|
||||
# Never cache index.html (or any HTML). The asset filenames are
|
||||
# content-hashed by Vite, so a fresh index.html is the only thing
|
||||
# that tells the browser to fetch the new bundle. Without this the
|
||||
# browser happily serves a stale index.html → stale bundle hash →
|
||||
# users see the old build until they hard-reload.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# Cache static assets (filenames are content-hashed, so 1y is safe)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
6094
frontend/package-lock.json
generated
6094
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,63 +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",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"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,139 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Timeline } from './components/timeline/Timeline'
|
||||
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||
import { MapView } from './components/map/MapView'
|
||||
import { PeopleView } from './components/people/PeopleView'
|
||||
import { TagsView } from './components/tags/TagsView'
|
||||
import { ColorsView } from './components/colors/ColorsView'
|
||||
import { RatedView } from './components/rated/RatedView'
|
||||
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 { SettingsDialog } from './components/dialogs/SettingsDialog'
|
||||
import { usePhotoStore } from './store/photoStore'
|
||||
import { useFilterStore } from './store/filterStore'
|
||||
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(true)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
// Right sidebar stays open by default and shows whatever's selected
|
||||
// (or an empty state if nothing is). User can still toggle it manually.
|
||||
// Note: deliberately NOT gated on viewMode — the preview overlay sits
|
||||
// on top with z-[1000], so leaving the sidebar mounted underneath
|
||||
// costs nothing visually and avoids the collapse-then-reopen layout
|
||||
// shift the user would otherwise see every time they exit preview.
|
||||
const showRightSidebar = rightSidebarOpen
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text">
|
||||
<TopBar
|
||||
leftSidebarOpen={leftSidebarOpen}
|
||||
rightSidebarOpen={showRightSidebar}
|
||||
onExpandLeft={() => setLeftSidebarOpen(true)}
|
||||
onExpandRight={() => setRightSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
leftSidebarOpen ? 'w-60' : 'w-0'
|
||||
} overflow-hidden border-r border-border bg-surface`}
|
||||
>
|
||||
<LeftSidebar
|
||||
onCollapse={() => setLeftSidebarOpen(false)}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</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. relative so the KeyboardHints overlay
|
||||
* centers against this column, not the viewport. */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
<FilterBar />
|
||||
<DiscardActionBar />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Section-level routing. The Map view replaces the timeline
|
||||
* with a Leaflet map of GPS-tagged photos; Duplicates gets its
|
||||
* own grouped grid; everything else falls through to the
|
||||
* filter-driven Timeline. */}
|
||||
{currentSection === 'map' ? (
|
||||
<MapView />
|
||||
) : currentSection === 'duplicates' ? (
|
||||
<DuplicatesView />
|
||||
) : currentSection === 'people' ? (
|
||||
<PeopleView />
|
||||
) : currentSection === 'tags' ? (
|
||||
<TagsView />
|
||||
) : currentSection === 'colors' ? (
|
||||
<ColorsView />
|
||||
) : currentSection === 'rated' ? (
|
||||
<RatedView />
|
||||
) : (
|
||||
<Timeline />
|
||||
)}
|
||||
</div>
|
||||
{/* Floating keyboard hints — bottom-center of the main column,
|
||||
* glassy. Mounted here so it's centered against the timeline,
|
||||
* not the viewport (which would be offset by the sidebars). */}
|
||||
<KeyboardHints />
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
showRightSidebar ? 'w-72' : 'w-0'
|
||||
} overflow-hidden border-l border-border bg-surface`}
|
||||
>
|
||||
<RightSidebar onCollapse={() => setRightSidebarOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan Progress Indicator */}
|
||||
<ScanProgress />
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer />
|
||||
|
||||
{/* Preview overlay — covers TopBar when active */}
|
||||
{viewMode === 'preview' && <PreviewView />}
|
||||
|
||||
{/* Settings panel — admin/maintenance actions */}
|
||||
<SettingsDialog
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 821 KiB |
@@ -1,62 +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: 'I', action: 'Info panel' },
|
||||
{ key: 'Esc', action: 'Deselect' },
|
||||
]
|
||||
: [
|
||||
{ key: '↑↓←→', action: 'Navigate' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Tab', action: 'Library panel' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: '/', action: 'Search' },
|
||||
]
|
||||
|
||||
return (
|
||||
// Absolute (not fixed) so the parent's flex/position context can
|
||||
// center it relative to the timeline area, not the viewport. Mount
|
||||
// inside the main column in App.tsx so it isn't offset by the
|
||||
// sidebar widths.
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
|
||||
{/* Near-opaque dark pill so the hints stay legible against busy
|
||||
* thumbnails. The previous bg-surface/40 + 5% white ring left
|
||||
* text washed out when a bright photo sat directly behind it. */}
|
||||
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md">
|
||||
{hints.map((hint, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
|
||||
{hint.key}
|
||||
</kbd>
|
||||
<span className="whitespace-nowrap text-xs text-white/85">
|
||||
{hint.action}
|
||||
</span>
|
||||
{i < hints.length - 1 && (
|
||||
<span className="ml-1 text-white/30">•</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-white/30">•</span>
|
||||
<span className="whitespace-nowrap text-xs font-semibold 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,132 +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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subdued icons — smaller and muted so the toast reads as a
|
||||
// background notification rather than a modal. The colored tint
|
||||
// comes from the border-left accent, not a filled background.
|
||||
const icons = {
|
||||
success: <CheckCircle className="h-3.5 w-3.5 text-pick" />,
|
||||
error: <XCircle className="h-3.5 w-3.5 text-reject" />,
|
||||
info: <Info className="h-3.5 w-3.5 text-primary" />,
|
||||
warning: <AlertCircle className="h-3.5 w-3.5 text-star" />,
|
||||
}
|
||||
|
||||
// Single thin left accent bar per type instead of a full-border +
|
||||
// tinted fill. Keeps the toast visually quiet — the user can still
|
||||
// glance it but it doesn't compete with the rest of the UI.
|
||||
const accents = {
|
||||
success: 'border-l-pick',
|
||||
error: 'border-l-reject',
|
||||
info: 'border-l-primary',
|
||||
warning: 'border-l-star',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-1.5">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={clsx(
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border border-border border-l-2 bg-surface/80 px-2.5 py-1.5 text-xs shadow-md backdrop-blur-md transition-all duration-300',
|
||||
'animate-slide-up',
|
||||
accents[toast.type]
|
||||
)}
|
||||
style={{ minWidth: '220px', maxWidth: '320px' }}
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">{icons[toast.type]}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-text">{toast.title}</div>
|
||||
{toast.message && (
|
||||
<div className="mt-0.5 truncate text-[11px] 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-1.5 py-0.5 text-[11px] 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-faint hover:bg-surface-offset hover:text-text"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { COLOR_LABEL_OPTIONS, type ColorLabel } from '../../constants/colorLabels'
|
||||
import { useCardGridNav } from '../../hooks/useCardGridNav'
|
||||
import { Timeline } from '../timeline/Timeline'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
interface ColorGroup {
|
||||
label: string
|
||||
value: ColorLabel | null
|
||||
className: string
|
||||
count: number
|
||||
representative: Photo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Colors view — two states:
|
||||
* 1. Grid of color label cards (default) — arrow keys + Enter to browse
|
||||
* 2. Detail view showing a color's photos in the full Timeline — Esc to go back
|
||||
*/
|
||||
export function ColorsView() {
|
||||
const { data: allPhotos = [], isLoading } = usePhotosQuery()
|
||||
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
||||
const [selectedGroup, setSelectedGroup] = useState<ColorGroup | null>(null)
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const buckets = new Map<string, Photo[]>()
|
||||
const uncolored: Photo[] = []
|
||||
|
||||
for (const photo of allPhotos) {
|
||||
if (photo.color_label) {
|
||||
const arr = buckets.get(photo.color_label) ?? []
|
||||
arr.push(photo)
|
||||
buckets.set(photo.color_label, arr)
|
||||
} else {
|
||||
uncolored.push(photo)
|
||||
}
|
||||
}
|
||||
|
||||
const result: ColorGroup[] = []
|
||||
for (const { value, className } of COLOR_LABEL_OPTIONS) {
|
||||
const photos = buckets.get(value) ?? []
|
||||
if (photos.length === 0) continue
|
||||
result.push({
|
||||
label: value.charAt(0).toUpperCase() + value.slice(1),
|
||||
value,
|
||||
className,
|
||||
count: photos.length,
|
||||
representative: photos[0],
|
||||
})
|
||||
}
|
||||
if (uncolored.length > 0) {
|
||||
result.push({
|
||||
label: 'Uncolored',
|
||||
value: null,
|
||||
className: 'bg-neutral-400',
|
||||
count: uncolored.length,
|
||||
representative: uncolored[0],
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [allPhotos])
|
||||
|
||||
const enterDetail = useCallback(
|
||||
(group: ColorGroup) => {
|
||||
setColorLabel((group.value ?? 'none') as ColorLabel)
|
||||
setSelectedGroup(group)
|
||||
},
|
||||
[setColorLabel]
|
||||
)
|
||||
|
||||
const exitDetail = useCallback(() => {
|
||||
setColorLabel(null)
|
||||
setSelectedGroup(null)
|
||||
}, [setColorLabel])
|
||||
|
||||
const { activeIndex, gridRef } = useCardGridNav({
|
||||
items: groups,
|
||||
inDetail: selectedGroup !== null,
|
||||
onEnter: enterDetail,
|
||||
onExit: exitDetail,
|
||||
})
|
||||
|
||||
if (selectedGroup) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<button
|
||||
onClick={exitDetail}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||
title="Back to colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-3 w-3 rounded-full ${selectedGroup.className}`} />
|
||||
<h2 className="text-sm font-semibold text-text">{selectedGroup.label}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading colors...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<Palette className="h-12 w-12 opacity-40" />
|
||||
<p className="text-sm">No color labels assigned yet</p>
|
||||
<p className="max-w-xs text-center text-xs opacity-70">
|
||||
Color labels will appear here once you assign them to photos.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-4 flex items-center gap-2 text-text-muted">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{groups.length} {groups.length === 1 ? 'color' : 'colors'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
|
||||
>
|
||||
{groups.map((group, i) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className={clsx(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
: 'border-border'
|
||||
)}
|
||||
onClick={() => enterDetail(group)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-surface-2">
|
||||
{group.representative ? (
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
|
||||
alt={group.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Palette className="h-10 w-10 text-text-muted/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
{group.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${group.className}`} />
|
||||
<p className="truncate text-xs font-medium text-text">{group.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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,835 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Loader2,
|
||||
Cpu,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
library,
|
||||
type MediaType,
|
||||
} from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
// React Query keys for the settings panels. Kept here (not in a shared
|
||||
// hook module) since they're internal to this dialog and used by the
|
||||
// runAction refresh step to invalidate after mutations.
|
||||
const SETTINGS_THUMB_STATS_KEY = ['settings', 'thumbnail-stats'] as const
|
||||
const SETTINGS_LIB_STATS_KEY = ['settings', 'library-stats'] as const
|
||||
const SETTINGS_WORKER_STATUS_KEY = ['settings', 'worker-status'] as const
|
||||
const SETTINGS_MISSING_STATS_KEY = ['settings', 'missing-stats'] as const
|
||||
// Shared with the DuplicatesView so a regroup invalidates the same cache
|
||||
// the grid renders from. Imported via the canonical hook key.
|
||||
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all "settings + admin" panel. Currently exposes the maintenance
|
||||
* endpoints exposed by /api/v1/library/maintenance/* — regenerate
|
||||
* thumbnails (with filters), run the data-integrity cleanup, and trigger
|
||||
* a full library re-scan. The thumbnail stats block is the entry point
|
||||
* users will look at to understand what's going on after a scan.
|
||||
*
|
||||
* Each action is gated by an in-flight flag so double-clicks don't
|
||||
* stack background jobs, and the stats block re-fetches whenever the
|
||||
* dialog opens or after any action completes.
|
||||
*/
|
||||
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAllErrors, setShowAllErrors] = useState(false)
|
||||
// One key per action so each button has its own spinner without
|
||||
// blocking the others.
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
|
||||
// All four panels fetch through React Query so cached data shows
|
||||
// instantly on reopen while a background refetch updates the numbers.
|
||||
// `enabled: isOpen` avoids firing requests while the dialog is closed,
|
||||
// but the cache entries survive between opens (default gcTime = 5m).
|
||||
const thumbStatsQuery = useQuery({
|
||||
queryKey: SETTINGS_THUMB_STATS_KEY,
|
||||
queryFn: library.maintenance.thumbnailStats,
|
||||
enabled: isOpen,
|
||||
// Treat as stale immediately so reopening the dialog triggers a
|
||||
// background refetch on top of the cached view.
|
||||
staleTime: 0,
|
||||
})
|
||||
const libStatsQuery = useQuery({
|
||||
queryKey: SETTINGS_LIB_STATS_KEY,
|
||||
queryFn: library.stats,
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
})
|
||||
// Worker status polls every 5s while the dialog is open — `refetchInterval`
|
||||
// replaces the old setInterval loop. Missing-stats is relatively cheap
|
||||
// but shares the same 5s rhythm to keep the orphan banner live.
|
||||
const workerStatusQuery = useQuery({
|
||||
queryKey: SETTINGS_WORKER_STATUS_KEY,
|
||||
queryFn: library.maintenance.workerStatus,
|
||||
enabled: isOpen,
|
||||
refetchInterval: isOpen ? 5000 : false,
|
||||
staleTime: 0,
|
||||
})
|
||||
const missingStatsQuery = useQuery({
|
||||
queryKey: SETTINGS_MISSING_STATS_KEY,
|
||||
queryFn: library.maintenance.missingStats,
|
||||
enabled: isOpen,
|
||||
refetchInterval: isOpen ? 5000 : false,
|
||||
staleTime: 0,
|
||||
})
|
||||
// Duplicates: shares the cache with DuplicatesView so a regroup
|
||||
// triggered from Settings updates the grid view immediately.
|
||||
const duplicatesQuery = useQuery({
|
||||
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
||||
queryFn: library.duplicates.groups,
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const thumbStats = thumbStatsQuery.data
|
||||
const libStats = libStatsQuery.data
|
||||
const workerStatus = workerStatusQuery.data
|
||||
const missingStats = missingStatsQuery.data
|
||||
// "loading" in the UI sense = fetching AND no cached data yet. Background
|
||||
// refetches on top of cached data shouldn't flip the refresh spinners.
|
||||
const loadingStats =
|
||||
(thumbStatsQuery.isFetching && !thumbStatsQuery.data) ||
|
||||
(libStatsQuery.isFetching && !libStatsQuery.data)
|
||||
const loadingWorkers =
|
||||
(workerStatusQuery.isFetching && !workerStatusQuery.data) ||
|
||||
(missingStatsQuery.isFetching && !missingStatsQuery.data)
|
||||
|
||||
const refreshStats = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
|
||||
}, [queryClient])
|
||||
const refreshWorkers = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_WORKER_STATUS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_MISSING_STATS_KEY })
|
||||
}, [queryClient])
|
||||
|
||||
// Surface fetch errors once (React Query de-dupes retries but we still
|
||||
// want a single toast so the user knows something went wrong).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (thumbStatsQuery.error || libStatsQuery.error) {
|
||||
console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error)
|
||||
toast.error('Could not load library stats')
|
||||
}
|
||||
}, [isOpen, thumbStatsQuery.error, libStatsQuery.error])
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (workerStatusQuery.error || missingStatsQuery.error) {
|
||||
console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error)
|
||||
toast.error('Could not load worker status')
|
||||
}
|
||||
}, [isOpen, workerStatusQuery.error, missingStatsQuery.error])
|
||||
|
||||
// Esc closes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const runAction = useCallback(
|
||||
async <T,>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
successTitle: string,
|
||||
describe?: (result: T) => string | undefined
|
||||
) => {
|
||||
if (busy[key]) return
|
||||
setBusy((b) => ({ ...b, [key]: true }))
|
||||
try {
|
||||
const result = await fn()
|
||||
toast.success(successTitle, describe?.(result))
|
||||
refreshStats()
|
||||
refreshWorkers()
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
toast.error(`${successTitle} failed`, message)
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [key]: false }))
|
||||
}
|
||||
},
|
||||
[busy, refreshStats, refreshWorkers]
|
||||
)
|
||||
|
||||
const regenerate = useCallback(
|
||||
(
|
||||
key: string,
|
||||
body: {
|
||||
media_types?: MediaType[]
|
||||
only_failed?: boolean
|
||||
only_pending?: boolean
|
||||
}
|
||||
) =>
|
||||
runAction(
|
||||
key,
|
||||
() => library.maintenance.regenerateThumbnails(body),
|
||||
'Regeneration queued',
|
||||
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
|
||||
),
|
||||
[runAction]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000]">
|
||||
<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 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-text">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Library overview */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<Database className="h-4 w-4" />}
|
||||
title="Library"
|
||||
right={
|
||||
<button
|
||||
onClick={refreshStats}
|
||||
disabled={loadingStats}
|
||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{loadingStats ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<Stat label="Photos" value={libStats?.total_photos} />
|
||||
<Stat label="Videos" value={libStats?.total_videos} />
|
||||
<Stat
|
||||
label="On disk"
|
||||
value={
|
||||
libStats ? `${libStats.total_size_gb.toFixed(1)} GB` : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
loading={busy.scan}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'scan',
|
||||
() => library.scan(),
|
||||
'Library scan started'
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Re-scan source folders
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Duplicate detection */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<Copy className="h-4 w-4" />}
|
||||
title="Duplicates"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<Stat
|
||||
label="Groups"
|
||||
value={duplicatesQuery.data?.total_groups}
|
||||
/>
|
||||
<Stat
|
||||
label="Members"
|
||||
value={duplicatesQuery.data?.total_members}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-text-muted">
|
||||
Duplicates are detected by perceptual hash (pHash), which catches
|
||||
visually-identical photos even when their bytes differ — re-encoded
|
||||
JPEGs, screenshots, resized exports. Backfill computes hashes for
|
||||
photos that existed before pHash was added; Re-detect re-runs
|
||||
clustering across the whole library.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<ActionButton
|
||||
loading={busy['backfill-phashes']}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'backfill-phashes',
|
||||
() => library.maintenance.backfillPhashes(),
|
||||
'pHash backfill queued'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Backfill perceptual hashes
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
loading={busy['regroup-duplicates']}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'regroup-duplicates',
|
||||
() => library.maintenance.regroupDuplicates(),
|
||||
'Duplicate detection queued'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Re-detect duplicates
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Thumbnail maintenance */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="Thumbnails"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<Stat
|
||||
label="Completed"
|
||||
value={thumbStats?.completed}
|
||||
tone="ok"
|
||||
/>
|
||||
<Stat
|
||||
label="Pending"
|
||||
value={thumbStats?.pending}
|
||||
tone="muted"
|
||||
/>
|
||||
<Stat
|
||||
label="Processing"
|
||||
value={thumbStats?.processing}
|
||||
tone="muted"
|
||||
/>
|
||||
<Stat
|
||||
label="Failed"
|
||||
value={thumbStats?.failed}
|
||||
tone={thumbStats && thumbStats.failed > 0 ? 'warn' : 'muted'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-text-muted">
|
||||
Reset on-disk thumbnails and re-queue generation. Use after
|
||||
upgrading the worker or to fix the gray placeholders left
|
||||
behind by an earlier failure.
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<ActionButton
|
||||
loading={busy['regen-videos']}
|
||||
onClick={() =>
|
||||
regenerate('regen-videos', { media_types: ['video'] })
|
||||
}
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
Regenerate video thumbnails
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
loading={busy['regen-failed']}
|
||||
onClick={() =>
|
||||
regenerate('regen-failed', { only_failed: true })
|
||||
}
|
||||
disabled={!!thumbStats && thumbStats.failed === 0}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Retry failed
|
||||
{thumbStats ? ` (${thumbStats.failed})` : ''}
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
loading={busy['regen-pending']}
|
||||
onClick={() =>
|
||||
regenerate('regen-pending', { only_pending: true })
|
||||
}
|
||||
disabled={!!thumbStats && thumbStats.pending === 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Kick pending
|
||||
{thumbStats ? ` (${thumbStats.pending})` : ''}
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
loading={busy['regen-all']}
|
||||
destructive
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
'Regenerate thumbnails for the entire library? ' +
|
||||
'This will queue every photo and may take a while.'
|
||||
)
|
||||
)
|
||||
return
|
||||
regenerate('regen-all', {})
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Regenerate all
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Worker fleet diagnostics */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<Cpu className="h-4 w-4" />}
|
||||
title="Workers"
|
||||
right={
|
||||
<button
|
||||
onClick={refreshWorkers}
|
||||
disabled={loadingWorkers}
|
||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{loadingWorkers ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* Top-line health */}
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<Stat
|
||||
label="Workers"
|
||||
value={workerStatus?.worker_count}
|
||||
tone={
|
||||
workerStatus
|
||||
? workerStatus.worker_count > 0
|
||||
? 'ok'
|
||||
: 'warn'
|
||||
: 'muted'
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="Broker"
|
||||
value={
|
||||
workerStatus
|
||||
? workerStatus.broker_ok
|
||||
? 'OK'
|
||||
: 'DOWN'
|
||||
: undefined
|
||||
}
|
||||
tone={
|
||||
workerStatus
|
||||
? workerStatus.broker_ok
|
||||
? 'ok'
|
||||
: 'warn'
|
||||
: 'muted'
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="Failed tasks"
|
||||
value={workerStatus?.failures.total}
|
||||
tone={
|
||||
workerStatus && workerStatus.failures.total > 0
|
||||
? 'warn'
|
||||
: 'muted'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error banners for the obvious failure modes */}
|
||||
{workerStatus?.broker_error && (
|
||||
<ErrorBanner
|
||||
title="Cannot reach Redis broker"
|
||||
detail={workerStatus.broker_error}
|
||||
/>
|
||||
)}
|
||||
{workerStatus?.inspect_error && (
|
||||
<ErrorBanner
|
||||
title="Celery inspect failed"
|
||||
detail={workerStatus.inspect_error}
|
||||
/>
|
||||
)}
|
||||
{workerStatus &&
|
||||
workerStatus.broker_ok &&
|
||||
workerStatus.worker_count === 0 && (
|
||||
<ErrorBanner
|
||||
title="No workers responding"
|
||||
detail="Broker is reachable but no celery worker pinged back. Check the mulita-worker container logs."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Queue depth */}
|
||||
{workerStatus && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Queue depth
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{Object.entries(workerStatus.queues).map(([name, depth]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between rounded bg-surface px-2 py-1"
|
||||
>
|
||||
<span className="text-text-muted">{name}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'font-mono font-semibold',
|
||||
depth > 0 ? 'text-text' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{depth}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orphaned rows (files gone from disk) */}
|
||||
{missingStats &&
|
||||
((missingStats.would_delete ?? 0) > 0 ||
|
||||
(missingStats.would_delete_folders ?? 0) > 0) && (
|
||||
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium text-text">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-star" />
|
||||
Orphaned rows
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-text-muted">
|
||||
{missingStats.would_delete ?? 0} photos and{' '}
|
||||
{missingStats.would_delete_folders ?? 0} folders
|
||||
point at paths that no longer exist on disk under a
|
||||
mounted source root. Usually means PHOTO_DIRS was
|
||||
repointed at a different library.
|
||||
{missingStats.skipped_unmounted > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
{missingStats.skipped_unmounted} more rows are
|
||||
under unmounted roots and will not be touched.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
loading={busy['prune-missing']}
|
||||
destructive
|
||||
onClick={() => {
|
||||
const photos = missingStats.would_delete ?? 0
|
||||
const folders = missingStats.would_delete_folders ?? 0
|
||||
if (
|
||||
!confirm(
|
||||
`Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` +
|
||||
'This cannot be undone.'
|
||||
)
|
||||
)
|
||||
return
|
||||
runAction(
|
||||
'prune-missing',
|
||||
() => library.maintenance.pruneMissing(),
|
||||
'Orphans pruned',
|
||||
(r) =>
|
||||
`${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted`
|
||||
)
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Prune
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-worker breakdown */}
|
||||
{workerStatus && workerStatus.workers.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Worker fleet
|
||||
</div>
|
||||
{workerStatus.workers.map((w) => (
|
||||
<div
|
||||
key={w.name}
|
||||
className="rounded border border-border bg-surface p-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{w.status === 'online' ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-pick" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-reject" />
|
||||
)}
|
||||
<span className="font-mono text-text">{w.name}</span>
|
||||
</div>
|
||||
<span className="text-text-muted">
|
||||
{w.active}/{w.concurrency ?? '?'} active
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-text-muted">
|
||||
<span>reserved: {w.reserved}</span>
|
||||
<span>scheduled: {w.scheduled}</span>
|
||||
{w.queues.length > 0 && (
|
||||
<span>queues: {w.queues.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
{w.active_tasks.length > 0 && (
|
||||
<div className="mt-1.5 space-y-0.5 border-t border-border pt-1.5">
|
||||
{w.active_tasks.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="truncate font-mono text-[10px] text-text-muted"
|
||||
title={`${t.name} ${JSON.stringify(t.args)}`}
|
||||
>
|
||||
<span className="text-text">{t.name}</span>{' '}
|
||||
{Array.isArray(t.args)
|
||||
? t.args.map((a) => String(a)).join(', ')
|
||||
: ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent task failures */}
|
||||
{workerStatus && workerStatus.failures.recent.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Recent failures ({workerStatus.failures.total})
|
||||
</div>
|
||||
{workerStatus.failures.recent.length > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAllErrors((v) => !v)}
|
||||
className="text-[10px] text-text-muted hover:text-text"
|
||||
>
|
||||
{showAllErrors ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-border bg-surface p-2">
|
||||
{(showAllErrors
|
||||
? workerStatus.failures.recent
|
||||
: workerStatus.failures.recent.slice(0, 5)
|
||||
).map((f) => (
|
||||
<div
|
||||
key={f.photo_id}
|
||||
className="border-b border-border/40 pb-1 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="truncate font-mono text-text">
|
||||
{f.filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-text-muted">
|
||||
{f.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="break-all font-mono text-[10px] text-reject">
|
||||
{f.error || '(no error message)'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent scan errors (Redis list) */}
|
||||
{workerStatus && workerStatus.scan_errors.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Scan errors
|
||||
</div>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto rounded border border-border bg-surface p-2 font-mono text-[10px] text-reject">
|
||||
{workerStatus.scan_errors.map((e, i) => (
|
||||
<div key={i} className="break-all">
|
||||
{e}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!workerStatus && (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading worker status…
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Data integrity */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
title="Maintenance"
|
||||
>
|
||||
<p className="text-xs text-text-muted">
|
||||
Re-runs the source-roots / folders / photos integrity
|
||||
cleanup that normally only runs on backend startup. Safe
|
||||
to run any time.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ActionButton
|
||||
loading={busy.cleanup}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'cleanup',
|
||||
() => library.maintenance.cleanup(),
|
||||
'Data integrity cleanup complete'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
Run data integrity cleanup
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local presentational helpers — kept private to this file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
title,
|
||||
right,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
right?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-5 last:mb-0">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-text">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{right}
|
||||
</div>
|
||||
<div className="rounded border border-border bg-bg p-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone = 'muted',
|
||||
}: {
|
||||
label: string
|
||||
value: number | string | undefined
|
||||
tone?: 'ok' | 'warn' | 'muted'
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'ok'
|
||||
? 'text-pick'
|
||||
: tone === 'warn'
|
||||
? 'text-reject'
|
||||
: 'text-text'
|
||||
return (
|
||||
<div className="rounded bg-surface p-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
{label}
|
||||
</div>
|
||||
<div className={clsx('text-base font-semibold', toneClass)}>
|
||||
{value ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorBanner({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="mt-3 rounded border border-reject/40 bg-reject/10 p-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium text-reject">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-0.5 break-all font-mono text-[10px] text-reject/80">
|
||||
{detail}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
loading,
|
||||
disabled,
|
||||
destructive,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
destructive?: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
destructive
|
||||
? 'border-reject/40 text-reject hover:bg-reject/10'
|
||||
: 'border-border text-text hover:bg-surface-2',
|
||||
(disabled || loading) && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{!loading && children}
|
||||
{loading && (
|
||||
// Re-render only the textual children when loading by stripping
|
||||
// the icon (children[0]) — we keep just the label so the spinner
|
||||
// takes the icon slot.
|
||||
Array.isArray(children) ? children.slice(1) : children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +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, stripPhotosFromCache } 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),
|
||||
// Pull the restored ids out of the discard view immediately. The
|
||||
// user is sitting on flag=discarded so they should disappear from
|
||||
// sight the moment the click lands; the onSuccess invalidate still
|
||||
// reconciles with server truth shortly after.
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, 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),
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, 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,433 +0,0 @@
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
useDuplicateGroupsQuery,
|
||||
DUPLICATE_GROUPS_QUERY_KEY,
|
||||
} from '../../hooks/useDuplicateGroupsQuery'
|
||||
import {
|
||||
photos as photosApi,
|
||||
type DuplicateGroup,
|
||||
type DuplicateGroupMember,
|
||||
} from '../../services/api'
|
||||
import {
|
||||
PhotoThumbnail,
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_ICON,
|
||||
THUMB_BADGE_NEUTRAL,
|
||||
THUMB_BADGE_PICK,
|
||||
} from '../timeline/PhotoThumbnail'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
/**
|
||||
* Sectioned grid view of duplicate clusters. Replaces the old flat
|
||||
* "is_duplicate=true" timeline. Each section is one cluster the
|
||||
* regroup_duplicates task identified — header on top with a count and a
|
||||
* "keep best, discard rest" button, members rendered as PhotoThumbnail
|
||||
* cards below.
|
||||
*
|
||||
* Mounted from App.tsx in place of <Timeline /> when the user is in the
|
||||
* duplicates section. Touches no filter store state.
|
||||
*/
|
||||
export function DuplicatesView() {
|
||||
const { data, isLoading, isError, error } = useDuplicateGroupsQuery()
|
||||
const queryClient = useQueryClient()
|
||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||
const selectPhoto = usePhotoStore((s) => s.selectPhoto)
|
||||
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
|
||||
// Bulk discard with the same undoable wrapper the timeline uses, so
|
||||
// Cmd+Z restores the discarded copies. invalidate ['library', 'duplicates']
|
||||
// so the group disappears from the view immediately.
|
||||
const discardMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||
onSuccess: (_, ids) => {
|
||||
registerUndoable(
|
||||
`Discarded ${ids.length} duplicate${ids.length === 1 ? '' : 's'}`,
|
||||
async () => {
|
||||
await photosApi.bulkRestore(ids)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Hooks below this point must run on every render — rules of hooks
|
||||
// forbid early returns above any useState/useEffect/useMemo. The early
|
||||
// loading/error/empty branches sit AFTER the hook block.
|
||||
const groups = data?.groups ?? []
|
||||
|
||||
// Flat sequence of member ids in visual order. Drives both preview
|
||||
// navigation and the in-grid keyboard walker. useMemo so the keyboard
|
||||
// effect doesn't tear down on every render.
|
||||
const allMemberIds = useMemo(
|
||||
() => groups.flatMap((g) => g.members.map((m) => m.id)),
|
||||
[groups]
|
||||
)
|
||||
|
||||
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||
// skip a row instead of jumping a single cell. The grid uses
|
||||
// `repeat(auto-fill, minmax(180px, 1fr))` so columns = floor(width/180).
|
||||
// We measure the FIRST section's grid container — every section uses
|
||||
// the same auto-fill rule so any one is representative.
|
||||
const [columns, setColumns] = useState(4)
|
||||
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
||||
setColumns(cols)
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
// Caller doesn't get the cleanup hook but ResizeObserver disconnects
|
||||
// when the element unmounts, which is fine for our lifecycle.
|
||||
}, [])
|
||||
|
||||
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
||||
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
||||
// grid so we approximate ↑/↓ via the measured `columns` count and
|
||||
// wrap ←/→ across group boundaries.
|
||||
useEffect(() => {
|
||||
if (allMemberIds.length === 0) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||
return
|
||||
}
|
||||
const key = e.key
|
||||
if (
|
||||
key !== 'ArrowLeft' &&
|
||||
key !== 'ArrowRight' &&
|
||||
key !== 'ArrowUp' &&
|
||||
key !== 'ArrowDown'
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const currentIdx = activePhotoId ? allMemberIds.indexOf(activePhotoId) : -1
|
||||
const startIdx = currentIdx >= 0 ? currentIdx : 0
|
||||
let nextIdx = startIdx
|
||||
if (key === 'ArrowLeft') nextIdx = startIdx - 1
|
||||
else if (key === 'ArrowRight') nextIdx = startIdx + 1
|
||||
else if (key === 'ArrowUp') nextIdx = startIdx - columns
|
||||
else if (key === 'ArrowDown') nextIdx = startIdx + columns
|
||||
// Clamp to bounds — we don't wrap on out-of-range vertical moves
|
||||
// since the grid is partitioned into groups and a "wrap" would
|
||||
// skip across visually unrelated content.
|
||||
nextIdx = Math.max(0, Math.min(allMemberIds.length - 1, nextIdx))
|
||||
const nextId = allMemberIds[nextIdx]
|
||||
if (!nextId) return
|
||||
selectPhoto(nextId)
|
||||
// Scroll the now-active cell into view if it's off-screen. The
|
||||
// PhotoThumbnail wrapper carries data-dup-id so we can find it
|
||||
// without threading refs through every cell.
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-dup-id="${nextId}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [allMemberIds, activePhotoId, columns, selectPhoto])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading duplicate groups…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
Could not load duplicate groups: {(error as any)?.message ?? 'unknown error'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-text-muted">
|
||||
<Sparkles className="h-8 w-8" />
|
||||
<div className="text-sm font-medium text-text">No duplicates found</div>
|
||||
<p className="max-w-sm text-xs">
|
||||
Nothing in your library matches another photo at the perceptual-hash
|
||||
level. If you've just added new photos, give the worker a minute and
|
||||
re-run "Re-detect duplicates" from Settings.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-bg p-4">
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{data?.total_groups} group{data?.total_groups === 1 ? '' : 's'} ·{' '}
|
||||
{data?.total_members} photo{data?.total_members === 1 ? '' : 's'}.
|
||||
Click "Keep best" to auto-discard all but the highest-resolution
|
||||
copy of each group. Cmd+Z to undo.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{groups.map((group, idx) => (
|
||||
<DuplicateGroupSection
|
||||
key={group.group_id}
|
||||
group={group}
|
||||
onKeepBest={(discardIds) => discardMutation.mutate(discardIds)}
|
||||
onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)}
|
||||
onSelectMember={(memberId) => selectPhoto(memberId)}
|
||||
selectedPhotos={selectedPhotos}
|
||||
isPending={discardMutation.isPending}
|
||||
// Hand the column-measurement ref to the first section only
|
||||
// — every section's grid uses the same auto-fill rule so any
|
||||
// one is representative of the rendered column count.
|
||||
gridRef={idx === 0 ? sampleGridRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DuplicateGroupSectionProps {
|
||||
group: DuplicateGroup
|
||||
onKeepBest: (discardIds: string[]) => void
|
||||
onPreviewMember: (memberId: string) => void
|
||||
onSelectMember: (memberId: string) => void
|
||||
selectedPhotos: string[]
|
||||
isPending: boolean
|
||||
/** Optional callback ref attached to this section's grid container.
|
||||
* Used by DuplicatesView to measure the rendered column count for
|
||||
* ↑/↓ keyboard navigation. Only the first section gets one. */
|
||||
gridRef?: (el: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
function DuplicateGroupSection({
|
||||
group,
|
||||
onKeepBest,
|
||||
onPreviewMember,
|
||||
onSelectMember,
|
||||
selectedPhotos,
|
||||
isPending,
|
||||
gridRef,
|
||||
}: DuplicateGroupSectionProps) {
|
||||
// Auto-pick "best" copy: highest pixel count, ties broken by file_size,
|
||||
// then earliest taken_at, then id for determinism. This is just the
|
||||
// default — the user can override it by clicking the crown button on
|
||||
// any other thumbnail (see `manualBestId`).
|
||||
const autoBest = useMemo(() => pickBestMember(group.members), [group.members])
|
||||
// When the user clicks "make this the best" on a non-default thumb,
|
||||
// we override the auto-pick. Local to the section so different groups
|
||||
// remember independent overrides; resets if the group itself changes.
|
||||
const [manualBestId, setManualBestId] = useState<string | null>(null)
|
||||
const bestId =
|
||||
manualBestId && group.members.some((m) => m.id === manualBestId)
|
||||
? manualBestId
|
||||
: autoBest.id
|
||||
const best = group.members.find((m) => m.id === bestId) ?? autoBest
|
||||
const discardCount = group.member_count - 1
|
||||
const isExact = group.reason === 'exact'
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-surface">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{isExact ? (
|
||||
<Copy className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<Layers className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
<span className="font-medium text-text">
|
||||
{group.member_count} {isExact ? 'exact' : 'similar'} photos
|
||||
</span>
|
||||
<span className="text-xs text-text-faint">
|
||||
keeping: {formatDimensions(best)}
|
||||
{best.file_size != null && ` · ${formatBytes(best.file_size)}`}
|
||||
{manualBestId && manualBestId !== autoBest.id && (
|
||||
<span className="ml-1 text-text-muted">(manual)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const discardIds = group.members
|
||||
.filter((m) => m.id !== best.id)
|
||||
.map((m) => m.id)
|
||||
if (discardIds.length === 0) return
|
||||
onKeepBest(discardIds)
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs font-medium transition-colors',
|
||||
'hover:border-reject/50 hover:bg-reject/10 hover:text-reject',
|
||||
isPending && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
title="Keep the highest-resolution copy and discard the rest"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Keep best, discard {discardCount}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid gap-1 p-2"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{group.members.map((member) => {
|
||||
const isBest = member.id === best.id
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
data-dup-id={member.id}
|
||||
className="group/dup relative"
|
||||
>
|
||||
<PhotoThumbnail
|
||||
photo={memberToPhoto(member)}
|
||||
size={180}
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(member.id)}
|
||||
onClick={() => onSelectMember(member.id)}
|
||||
onDoubleClick={() => onPreviewMember(member.id)}
|
||||
/>
|
||||
{/* BEST pill — top-right, pick-coloured. Composes the same
|
||||
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
||||
* set of ornaments (selection, rating, flags, BEST) reads
|
||||
* as one consistent chip system. Inset 1.5 (6px) rather
|
||||
* than 1 (4px) because these are SIBLINGS of the thumbnail,
|
||||
* not inside its overflow-hidden box, so they need
|
||||
* clearance from PhotoThumbnail's outer selection ring. */}
|
||||
{isBest && (
|
||||
<span
|
||||
className={clsx(
|
||||
'pointer-events-none absolute right-1.5 top-1.5 z-10 uppercase',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_PICK
|
||||
)}
|
||||
>
|
||||
<Crown className={THUMB_BADGE_ICON} />
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
{/* "Keep this" — shown on hover for non-best thumbnails.
|
||||
* Mirrors BEST's placement so the eye doesn't retarget
|
||||
* while scanning. Starts neutral and shifts to pick on
|
||||
* hover as a preview of the state it'll set. */}
|
||||
{!isBest && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setManualBestId(member.id)
|
||||
}}
|
||||
className={clsx(
|
||||
'absolute right-1.5 top-1.5 z-10 hidden uppercase transition hover:bg-pick group-hover/dup:inline-flex',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL
|
||||
)}
|
||||
title="Keep this one instead"
|
||||
>
|
||||
<Crown className={THUMB_BADGE_ICON} />
|
||||
Keep this
|
||||
</button>
|
||||
)}
|
||||
{/* Dimensions chip — bottom-LEFT. Neutral metadata variant
|
||||
* matches the family. Rare collision with a manual rating
|
||||
* (also bottom-left) is tolerated: rated duplicates are
|
||||
* uncommon in practice. */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute bottom-1 left-1 z-10 font-mono',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL
|
||||
)}
|
||||
>
|
||||
{formatDimensions(member)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Score a member by (pixels, file_size, -taken_at) and return the winner.
|
||||
* Larger pixel count wins; ties broken by file_size; final tie by earliest
|
||||
* taken_at (more likely the original capture). */
|
||||
function pickBestMember(members: DuplicateGroupMember[]): DuplicateGroupMember {
|
||||
return members.reduce((best, m) => {
|
||||
const bestPixels = (best.width ?? 0) * (best.height ?? 0)
|
||||
const mPixels = (m.width ?? 0) * (m.height ?? 0)
|
||||
if (mPixels !== bestPixels) return mPixels > bestPixels ? m : best
|
||||
const bestSize = best.file_size ?? 0
|
||||
const mSize = m.file_size ?? 0
|
||||
if (mSize !== bestSize) return mSize > bestSize ? m : best
|
||||
// Earliest taken_at wins (treat null as far-future).
|
||||
const bestTaken = best.taken_at ?? '9999'
|
||||
const mTaken = m.taken_at ?? '9999'
|
||||
if (mTaken !== bestTaken) return mTaken < bestTaken ? m : best
|
||||
return best
|
||||
})
|
||||
}
|
||||
|
||||
function formatDimensions(m: DuplicateGroupMember): string {
|
||||
if (!m.width || !m.height) return '?'
|
||||
const mp = (m.width * m.height) / 1_000_000
|
||||
if (mp >= 1) return `${mp.toFixed(1)}MP`
|
||||
return `${m.width}×${m.height}`
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`
|
||||
return `${n}B`
|
||||
}
|
||||
|
||||
/** Adapt a DuplicateGroupMember (the slim API shape) to a Photo, which
|
||||
* is what PhotoThumbnail expects. We deliberately set is_duplicate=false
|
||||
* on the synthetic Photo so the duplicate badge isn't drawn on every
|
||||
* cell — the entire view is duplicates, the badge would be redundant. */
|
||||
function memberToPhoto(m: DuplicateGroupMember): Photo {
|
||||
return {
|
||||
id: m.id,
|
||||
filepath: m.filename, // good enough for the RAW/video extension regex
|
||||
filename: m.filename,
|
||||
media_type: m.media_type,
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
taken_at: m.taken_at,
|
||||
rating: 0,
|
||||
is_discarded: false,
|
||||
is_duplicate: false,
|
||||
file_hash: m.file_hash ?? '',
|
||||
folder_id: m.folder_id,
|
||||
added_at: null,
|
||||
thumb_small: m.thumb_small ?? undefined,
|
||||
}
|
||||
}
|
||||
@@ -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="h-7 w-full rounded-full border border-border bg-surface-2 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,169 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ShoppingBasket } from 'lucide-react'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||
import cardBg from '../../assets/card.png'
|
||||
|
||||
/** How many thumbnails fan out across the stack at once. The newest is
|
||||
* drawn last (top), older ones fan back-left and back-right. */
|
||||
const STACK_SIZE = 5
|
||||
|
||||
/**
|
||||
* Pinned card at the bottom of the LeftSidebar showing the currently
|
||||
* active heap. Renders nothing when no heap is active — the parent
|
||||
* layout collapses around it cleanly.
|
||||
*
|
||||
* The card is a fast nav shortcut + a satisfying landing spot for the P
|
||||
* pick action: every new pick optimistically updates the
|
||||
* ['heap-photo-ids', heapId] cache that the existing pick mutation
|
||||
* already maintains, so we just subscribe to the same query and let
|
||||
* framer-motion's AnimatePresence handle the entrance/exit animation
|
||||
* when ids appear or disappear.
|
||||
*/
|
||||
export function ActiveHeapCard() {
|
||||
const { activeHeap } = useActiveHeapMembers()
|
||||
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||
|
||||
// Subscribes to the same cache key the pick mutation optimistically
|
||||
// updates. The data is already fetched (and kept fresh) by
|
||||
// useActiveHeapMembers above; this useQuery just gives us a render-
|
||||
// dependency on the array contents and stable insertion order.
|
||||
const { data: orderedIds = [] } = useQuery<string[]>({
|
||||
queryKey: ['heap-photo-ids', activeHeap?.id],
|
||||
queryFn: () => heapsApi.photoIds(activeHeap!.id),
|
||||
enabled: !!activeHeap,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
if (!activeHeap) return null
|
||||
|
||||
// Show the latest STACK_SIZE photos. The backend returns ids in
|
||||
// insertion order so the last one in the array is the most recently
|
||||
// picked — that's the one we want at the front of the stack.
|
||||
const visible = orderedIds.slice(-STACK_SIZE)
|
||||
// We render newest LAST so it draws on top via z-index. Reverse so
|
||||
// index 0 is the back card and index N-1 is the front.
|
||||
const stack = visible.map((id, i) => ({
|
||||
id,
|
||||
// Symmetric fan: front card has rotate=0, x=0; cards behind it
|
||||
// alternate left/right as you walk back through the stack.
|
||||
rotate: stackRotate(i, visible.length),
|
||||
x: stackOffsetX(i, visible.length),
|
||||
y: stackOffsetY(i, visible.length),
|
||||
z: i,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="m-1.5 rounded-md border border-border bg-surface-2 shadow-sm">
|
||||
{/* Header — clickable, navigates to the heap section. */}
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToSection(`heap-${activeHeap.id}`, { heapId: activeHeap.id })
|
||||
}
|
||||
className="flex w-full items-center gap-1.5 rounded-t-md px-2 py-1.5 text-left hover:bg-surface-offset"
|
||||
title={`Open "${activeHeap.name}"`}
|
||||
>
|
||||
<ShoppingBasket className="h-3.5 w-3.5 flex-shrink-0 text-pick" />
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-semibold text-text">
|
||||
{activeHeap.name}
|
||||
</span>
|
||||
<span className="flex h-4 min-w-[20px] items-center justify-center rounded bg-surface px-1 text-[10px] font-medium text-text-muted">
|
||||
{orderedIds.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Stack row. Re-keyed on activeHeap.id so switching heaps tears
|
||||
* the animation context down cleanly instead of trying to
|
||||
* crossfade unrelated photos. The desert scene sits behind the
|
||||
* fanned thumbnails — `cover` + `bottom` keeps the dunes anchored
|
||||
* so the cacti frame the photos rather than the (transparent) sky. */}
|
||||
<div
|
||||
key={activeHeap.id}
|
||||
className="relative h-20 overflow-hidden rounded-b-md px-2 pb-2"
|
||||
style={{
|
||||
backgroundImage: `url(${cardBg})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center bottom',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
imageRendering: 'pixelated',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-[11px] text-text-faint">
|
||||
Pick photos with P to fill the heap
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-full">
|
||||
<AnimatePresence initial={false}>
|
||||
{stack.map((item) => (
|
||||
<motion.img
|
||||
key={item.id}
|
||||
src={photosApi.getThumbnailUrl(item.id, 'small')}
|
||||
alt=""
|
||||
initial={{ x: 80, y: 0, scale: 0.6, rotate: 0, opacity: 0 }}
|
||||
animate={{
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
scale: 1,
|
||||
rotate: item.rotate,
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{ x: -60, scale: 0.6, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 28 }}
|
||||
style={{
|
||||
zIndex: item.z,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
marginLeft: -26, // half of w-13
|
||||
marginTop: -26, // half of h-13
|
||||
}}
|
||||
className="h-[52px] w-[52px] rounded object-cover shadow-[0_4px_10px_rgba(0,0,0,0.65),0_2px_4px_rgba(0,0,0,0.5)] ring-1 ring-black/60"
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stack geometry ───────────────────────────────────────────────────────
|
||||
//
|
||||
// `i` is the position in the visible array (0 = oldest, last = newest).
|
||||
// We want the newest card at center (rotate 0, x 0) and earlier cards
|
||||
// fanning symmetrically outward — so we score each card by how far it
|
||||
// is from the front, alternating sign.
|
||||
|
||||
const X_STEP = 18 // pixels per fan step
|
||||
const Y_STEP = 2 // tiny vertical lift so the back cards peek above
|
||||
const ROTATE_STEP = 6 // degrees per fan step
|
||||
|
||||
function stackRotate(i: number, len: number): number {
|
||||
// Distance from the front (newest). Front card → 0, then alternating
|
||||
// -1, +1, -2, +2 ... to spread cards outward.
|
||||
const fromFront = len - 1 - i
|
||||
if (fromFront === 0) return 0
|
||||
const sign = fromFront % 2 === 1 ? -1 : 1
|
||||
const magnitude = Math.ceil(fromFront / 2)
|
||||
return sign * magnitude * ROTATE_STEP
|
||||
}
|
||||
|
||||
function stackOffsetX(i: number, len: number): number {
|
||||
const fromFront = len - 1 - i
|
||||
if (fromFront === 0) return 0
|
||||
const sign = fromFront % 2 === 1 ? -1 : 1
|
||||
const magnitude = Math.ceil(fromFront / 2)
|
||||
return sign * magnitude * X_STEP
|
||||
}
|
||||
|
||||
function stackOffsetY(i: number, len: number): number {
|
||||
// Back cards lift up a couple pixels so they're visible above the
|
||||
// front card's top edge — gives the stack its sense of depth.
|
||||
const fromFront = len - 1 - i
|
||||
return fromFront * -Y_STEP
|
||||
}
|
||||
@@ -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,497 +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 — eyebrow style to match the LeftSidebar
|
||||
* library/folders headers. */}
|
||||
<div
|
||||
className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
|
||||
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>
|
||||
<span className="flex-1 truncate">Heaps</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCreating(true)
|
||||
setExpanded(true)
|
||||
}}
|
||||
className="invisible rounded p-0.5 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-0.5"
|
||||
style={{ paddingLeft: '20px' }}
|
||||
>
|
||||
<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-[11px] 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-[11px] text-white hover:bg-primary/80 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heaps.length === 0 && !creating && (
|
||||
<div
|
||||
className="px-2 py-0.5 text-[11px] text-text-faint"
|
||||
style={{ paddingLeft: '20px' }}
|
||||
>
|
||||
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 h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
// Active-heap row gets a soft primary wash so the
|
||||
// user always knows where Pick / T will land, even
|
||||
// when viewing a different section.
|
||||
isActive && !isFiltered && 'bg-primary/8 text-text',
|
||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||
)}
|
||||
style={{ paddingLeft: '20px' }}
|
||||
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-3.5 w-3.5 flex-shrink-0',
|
||||
isActive || 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('truncate', isActive && 'font-semibold')}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span
|
||||
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
|
||||
title="Active heap — Pick (P) and the basket badge on photos point here"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
||||
{heap.photo_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-4 min-w-[20px] 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,789 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Image,
|
||||
Star,
|
||||
Trash2,
|
||||
HardDrive,
|
||||
Copy,
|
||||
Tag as TagIcon,
|
||||
Palette,
|
||||
MapPin,
|
||||
Layers2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
PanelLeftClose,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { sourceFolders, 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 { ActiveHeapCard } from '../heaps/ActiveHeapCard'
|
||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
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'
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
onCollapse: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||
// 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: faceClusters = [] } = useTagsQuery('face_cluster')
|
||||
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),
|
||||
// Optimistically pull the dropped photos out of the timeline so the
|
||||
// grid reflows the moment the drop lands, instead of waiting for
|
||||
// the network round-trip + invalidation refetch.
|
||||
onMutate: (photoIds) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(photoIds)
|
||||
stripPhotosFromCache(queryClient, 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, groupBy: 'rating' })
|
||||
break
|
||||
case 'discarded':
|
||||
navigateToSection('discarded', { flag: 'discarded' })
|
||||
break
|
||||
case 'duplicates':
|
||||
navigateToSection('duplicates', { duplicates: true })
|
||||
break
|
||||
case 'tags':
|
||||
navigateToSection('tags', { groupBy: 'tag' })
|
||||
break
|
||||
case 'people':
|
||||
navigateToSection('people', { groupBy: 'tag' })
|
||||
break
|
||||
case 'colors':
|
||||
navigateToSection('colors', { groupBy: 'color' })
|
||||
break
|
||||
case 'map':
|
||||
navigateToSection('map', {})
|
||||
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'),
|
||||
})
|
||||
|
||||
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 (user tags only).
|
||||
const userTags = allTags.filter((t) => t.kind === 'user')
|
||||
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
|
||||
const peopleTotalCount = faceClusters.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: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount },
|
||||
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
|
||||
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
|
||||
{ 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
|
||||
// Top-level entries (Views, Folders) render as small uppercase
|
||||
// section eyebrows rather than another tree row, so the panel reads
|
||||
// as distinct sections with the actual items beneath them.
|
||||
const isSectionHeader = depth === 0
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-center gap-1',
|
||||
isSectionHeader
|
||||
? 'mt-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text'
|
||||
: clsx(
|
||||
// Fixed h-[24px] (not min-h) locks the row height so the
|
||||
// hover-only kebab button can't grow the row vertically.
|
||||
'h-[24px] rounded px-2 text-[12px] leading-none',
|
||||
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')
|
||||
)}
|
||||
style={
|
||||
isSectionHeader
|
||||
? undefined
|
||||
: { paddingLeft: `${8 + (depth - 1) * 12}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>
|
||||
) : (
|
||||
!isSectionHeader && <div className="w-3" />
|
||||
)}
|
||||
|
||||
{/* Item Icon — section headers drop their icon in favor of the
|
||||
* uppercase eyebrow label. */}
|
||||
{item.icon && !isSectionHeader && (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">{item.icon}</span>
|
||||
</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. Section headers skip
|
||||
* the badge entirely (they're labels, not navigable rows). */}
|
||||
{!isSectionHeader && (
|
||||
item.count !== undefined && item.count > 0 ? (
|
||||
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-4 min-w-[20px] 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">
|
||||
{/* Header with collapse button. Matches the right sidebar header
|
||||
* so both panels have symmetric affordances. */}
|
||||
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||
Library
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Collapse panel (Tab)"
|
||||
aria-label="Collapse panel"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Active heap card — pinned just below the Library header so
|
||||
* toasts (bottom-left fixed) can't cover it. Returns null when
|
||||
* no heap is active, so the layout collapses cleanly. */}
|
||||
<ActiveHeapCard />
|
||||
|
||||
{/* Tree View */}
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
{libraryTree.map((item) => renderTreeItem(item))}
|
||||
<HeapsPanel />
|
||||
</div>
|
||||
|
||||
{/* Settings entry point — pinned to the bottom of the panel so it
|
||||
* sits out of the way of the library tree but is always reachable. */}
|
||||
<div className="border-t border-border p-1.5">
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</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,469 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Star, Info, ShoppingBasket, Trash2, Plus, PanelRightClose } 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 { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
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.
|
||||
*/
|
||||
interface RightSidebarProps {
|
||||
onCollapse: () => void
|
||||
}
|
||||
|
||||
export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
||||
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
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,
|
||||
})
|
||||
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),
|
||||
// Yank the photos from the timeline before the network round-trip
|
||||
// so the grid reflows immediately. Same pattern as the X hotkey
|
||||
// path in useKeyboardShortcuts.
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, 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],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Unified header rendered in every branch so the collapse button is
|
||||
// always reachable regardless of selection state. Title and the
|
||||
// clear-selection X adapt to what's selected.
|
||||
const headerTitle =
|
||||
selectedPhotos.length === 0
|
||||
? 'Metadata'
|
||||
: selectedPhotos.length === 1
|
||||
? 'Metadata'
|
||||
: `${selectedPhotos.length} Photos Selected`
|
||||
|
||||
const Header = () => (
|
||||
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||
{headerTitle}
|
||||
</h2>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{selectedPhotos.length > 0 && (
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear selection (Esc)"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Collapse panel (I)"
|
||||
aria-label="Collapse panel"
|
||||
>
|
||||
<PanelRightClose className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (selectedPhotos.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
<Header />
|
||||
<div className="flex flex-1 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>
|
||||
</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">
|
||||
<Header />
|
||||
<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">
|
||||
<Header />
|
||||
|
||||
<div className="space-y-2.5 border-b border-border p-3">
|
||||
<p className="text-[11px] 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,118 +0,0 @@
|
||||
import { PanelLeftOpen, PanelRightOpen } from 'lucide-react'
|
||||
import desertBg from '../../assets/desert.png'
|
||||
import muleSprites from '../../assets/mule-sprites.png'
|
||||
|
||||
interface TopBarProps {
|
||||
leftSidebarOpen: boolean
|
||||
rightSidebarOpen: boolean
|
||||
onExpandLeft: () => void
|
||||
onExpandRight: () => void
|
||||
}
|
||||
|
||||
// Block-character ASCII rendering of "Mulimago" — sits on a black plate
|
||||
// in place of the old text title.
|
||||
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
|
||||
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
|
||||
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`
|
||||
|
||||
const ROMAN_PAIRS: [number, string][] = [
|
||||
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
|
||||
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
|
||||
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
|
||||
]
|
||||
|
||||
function toRoman(n: number): string {
|
||||
let result = ''
|
||||
for (const [value, symbol] of ROMAN_PAIRS) {
|
||||
while (n >= value) {
|
||||
result += symbol
|
||||
n -= value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim top bar — animated walking mule on the left over a tiled desert
|
||||
* backdrop, settings gear on the right. The active heap badge moved into
|
||||
* the Heaps panel in the left sidebar (where it actually relates to the
|
||||
* heap rows the user navigates to).
|
||||
*
|
||||
* Also hosts the "expand sidebar" affordances: when a side panel is
|
||||
* collapsed, a small panel-open icon appears on the corresponding edge
|
||||
* so the user has a way to bring it back without hunting for the
|
||||
* keyboard shortcut. When the panel is open, the button hides — its
|
||||
* collapse twin lives in the panel's own header.
|
||||
*/
|
||||
export function TopBar({
|
||||
leftSidebarOpen,
|
||||
rightSidebarOpen,
|
||||
onExpandLeft,
|
||||
onExpandRight,
|
||||
}: TopBarProps) {
|
||||
return (
|
||||
<header
|
||||
className="relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4"
|
||||
style={{
|
||||
// Two layers: desert tile on top (scrolls right→left), dusk-sky
|
||||
// gradient underneath (static). Tile width is fixed at 200px so
|
||||
// the `desert-scroll` keyframe can move by exactly one tile and
|
||||
// loop seamlessly.
|
||||
backgroundImage: `url(${desertBg}), linear-gradient(to bottom, #2b3a5c 0%, #6b6b8a 35%, #d68a5c 75%, #f0c188 100%)`,
|
||||
backgroundRepeat: 'repeat-x, no-repeat',
|
||||
backgroundSize: '200px 100%, 100% 100%',
|
||||
backgroundPosition: '0 bottom, 0 0',
|
||||
imageRendering: 'pixelated',
|
||||
animation: 'desert-scroll 24s linear infinite',
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center gap-3">
|
||||
{!leftSidebarOpen && (
|
||||
<button
|
||||
onClick={onExpandLeft}
|
||||
className="rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
|
||||
title="Expand panel (Tab)"
|
||||
aria-label="Expand left panel"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
aria-label="Mulimago"
|
||||
className="h-12 w-14"
|
||||
style={{
|
||||
backgroundImage: `url(${muleSprites})`,
|
||||
backgroundSize: '300% 200%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
imageRendering: 'pixelated',
|
||||
animation: 'mule-walk 0.6s steps(1) infinite',
|
||||
}}
|
||||
/>
|
||||
<pre
|
||||
aria-label="Mulimago"
|
||||
className="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-text"
|
||||
style={{ letterSpacing: 0 }}
|
||||
>
|
||||
{MULIMAGO_ASCII}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex h-full items-end gap-2 self-stretch pb-1">
|
||||
{!rightSidebarOpen && (
|
||||
<button
|
||||
onClick={onExpandRight}
|
||||
className="self-center rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
|
||||
title="Expand panel (I)"
|
||||
aria-label="Expand right panel"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className="text-[10px] font-serif text-black/80"
|
||||
>
|
||||
Built with hubris • {toRoman(new Date().getFullYear())}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { MapContainer, TileLayer, Marker, useMap } from 'react-leaflet'
|
||||
// react-leaflet-cluster has no own .d.ts that survives strict mode in
|
||||
// every project, so we let TS infer from its runtime export.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore — package ships JS with no bundled types
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { MapPin } from 'lucide-react'
|
||||
|
||||
interface MapPoint {
|
||||
id: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
taken_at: string | null
|
||||
}
|
||||
|
||||
/** Build the divIcon used for each photo marker. The HTML is a tiny
|
||||
* square thumbnail with a white border + drop shadow so it reads on
|
||||
* any tile background. Memoised per-photo so we don't re-create the
|
||||
* L.DivIcon on every re-render. */
|
||||
function buildPhotoIcon(photoId: string): L.DivIcon {
|
||||
const url = photosApi.getThumbnailUrl(photoId, 'small')
|
||||
return L.divIcon({
|
||||
className: 'photo-map-marker',
|
||||
html: `<div class="pmm-frame"><img src="${url}" loading="lazy" alt="" /></div>`,
|
||||
iconSize: [56, 56],
|
||||
iconAnchor: [28, 28],
|
||||
popupAnchor: [0, -28],
|
||||
})
|
||||
}
|
||||
|
||||
/** Pans/zooms the map to fit the supplied points the first time they
|
||||
* arrive. Subsequent loads (e.g. cache refresh) leave the user's pan
|
||||
* alone — they're probably mid-investigation. */
|
||||
function FitBoundsOnce({ points }: { points: MapPoint[] }) {
|
||||
const map = useMap()
|
||||
const fittedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (fittedRef.current || points.length === 0) return
|
||||
const bounds = L.latLngBounds(points.map((p) => [p.latitude, p.longitude]))
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
|
||||
fittedRef.current = true
|
||||
}, [points, map])
|
||||
return null
|
||||
}
|
||||
|
||||
export function MapView() {
|
||||
const { data: points = [], isLoading, error } = useQuery({
|
||||
queryKey: ['photos', 'map'],
|
||||
queryFn: () => photosApi.mapPoints(),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
// Marker click hands off to the same PreviewView the timeline uses, so
|
||||
// the user gets the full preview UI (large image, info panel with
|
||||
// location, filmstrip nav between map photos) instead of a bespoke
|
||||
// map-only lightbox. We pass the map's own point order as the visible
|
||||
// sequence so left/right arrows step through neighboring markers.
|
||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||
|
||||
// Stable per-marker icons. Re-created only when the set of point ids
|
||||
// changes — the underlying L.DivIcon objects are pure HTML so reusing
|
||||
// them is safe across re-renders.
|
||||
const iconsById = useMemo(() => {
|
||||
const map = new Map<string, L.DivIcon>()
|
||||
for (const p of points) map.set(p.id, buildPhotoIcon(p.id))
|
||||
return map
|
||||
}, [points])
|
||||
|
||||
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading map…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-destructive">
|
||||
Failed to load map photos
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{points.length === 0 ? (
|
||||
<div className="absolute inset-0 z-[400] flex items-center justify-center bg-background/80 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-center text-sm text-muted-foreground">
|
||||
<MapPin className="h-6 w-6" />
|
||||
<div>No photos with GPS data yet.</div>
|
||||
<div className="text-xs">
|
||||
Re-run metadata extraction from Settings → Backfill GPS to
|
||||
populate coordinates from existing photos.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
minZoom={2}
|
||||
worldCopyJump
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitBoundsOnce points={points} />
|
||||
<MarkerClusterGroup chunkedLoading maxClusterRadius={50}>
|
||||
{points.map((p) => {
|
||||
const icon = iconsById.get(p.id)
|
||||
if (!icon) return null
|
||||
return (
|
||||
<PhotoMarker
|
||||
key={p.id}
|
||||
point={p}
|
||||
icon={icon}
|
||||
onClick={() => openPreview(p.id, visibleSequence)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
<style>{`
|
||||
.photo-map-marker { background: transparent; border: none; }
|
||||
.photo-map-marker .pmm-frame {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
background: #1f2937;
|
||||
}
|
||||
.photo-map-marker .pmm-frame img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhotoMarker({
|
||||
point,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
point: MapPoint
|
||||
icon: L.DivIcon
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Marker
|
||||
position={[point.latitude, point.longitude]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: onClick }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import {
|
||||
tags as tagsApi,
|
||||
photos as photosApi,
|
||||
type Tag,
|
||||
} from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { useCardGridNav } from '../../hooks/useCardGridNav'
|
||||
import { Timeline } from '../timeline/Timeline'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
/**
|
||||
* People view — two states:
|
||||
* 1. Grid of face cluster cards (default) — arrow keys + Enter to browse
|
||||
* 2. Detail view showing a person's photos in the full Timeline — Esc to go back
|
||||
*/
|
||||
export function PeopleView() {
|
||||
const { data: clusters = [], isLoading } = useTagsQuery('face_cluster')
|
||||
const queryClient = useQueryClient()
|
||||
const setTagIds = useFilterStore((s) => s.setTagIds)
|
||||
|
||||
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
tagsApi.update(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
setEditingId(null)
|
||||
if (selectedPerson && editingId === selectedPerson.id) {
|
||||
setSelectedPerson({ ...selectedPerson, name: editName.trim() })
|
||||
}
|
||||
toast.success('Renamed')
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Rename failed', e?.response?.data?.detail || e.message),
|
||||
})
|
||||
|
||||
const startEditing = (tag: Tag) => {
|
||||
setEditingId(tag.id)
|
||||
setEditName(tag.name)
|
||||
}
|
||||
|
||||
const submitRename = () => {
|
||||
if (!editingId || !editName.trim()) return
|
||||
renameMutation.mutate({ id: editingId, name: editName.trim() })
|
||||
}
|
||||
|
||||
const enterDetail = useCallback(
|
||||
(person: Tag) => {
|
||||
setTagIds([person.id])
|
||||
setSelectedPerson(person)
|
||||
},
|
||||
[setTagIds]
|
||||
)
|
||||
|
||||
const exitDetail = useCallback(() => {
|
||||
setTagIds([])
|
||||
setSelectedPerson(null)
|
||||
}, [setTagIds])
|
||||
|
||||
const { activeIndex, gridRef } = useCardGridNav({
|
||||
items: clusters,
|
||||
inDetail: selectedPerson !== null,
|
||||
onEnter: enterDetail,
|
||||
onExit: exitDetail,
|
||||
})
|
||||
|
||||
// ── Detail view: a person's photos ─────────────────────────────────
|
||||
if (selectedPerson) {
|
||||
const isEditing = editingId === selectedPerson.id
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<button
|
||||
onClick={exitDetail}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||
title="Back to people"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button onClick={submitRename} className="rounded p-1 text-green-500 hover:bg-green-500/10">
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="rounded p-1 text-text-muted hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-text">{selectedPerson.name}</h2>
|
||||
<button
|
||||
onClick={() => startEditing(selectedPerson)}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:text-text"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Card grid ──────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading people...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<Users className="h-12 w-12 opacity-40" />
|
||||
<p className="text-sm">No people identified yet</p>
|
||||
<p className="max-w-xs text-center text-xs opacity-70">
|
||||
Face detection runs automatically when photos are scanned.
|
||||
People will appear here once faces are found and clustered.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-4 flex items-center gap-2 text-text-muted">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{clusters.length} {clusters.length === 1 ? 'person' : 'people'} identified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
|
||||
>
|
||||
{clusters.map((tag, i) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={clsx(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
: editingId === tag.id
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
: 'border-border'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (editingId !== tag.id) enterDetail(tag)
|
||||
}}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-surface-2">
|
||||
{tag.representative_photo_id ? (
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
|
||||
alt={tag.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Users className="h-10 w-10 text-text-muted/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
{tag.photo_count}
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
startEditing(tag)
|
||||
}}
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5">
|
||||
{editingId === tag.id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-bg px-1.5 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button onClick={submitRename} className="rounded p-0.5 text-green-500 hover:bg-green-500/10">
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="rounded p-0.5 text-text-muted hover:bg-surface-2">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,277 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { X, Info } from 'lucide-react'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
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 visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
|
||||
|
||||
// Snapshot the photo we opened on, captured once at mount via the
|
||||
// store's getState (which is guaranteed to reflect the value the
|
||||
// openPreview action just wrote, even if the React subscription
|
||||
// hasn't been delivered to this component's first render yet). This
|
||||
// is the id we'll restore on close, no matter how many neighbours
|
||||
// the user arrows through inside the preview.
|
||||
const openOriginRef = useRef<string | null>(
|
||||
activePhotoId ?? usePhotoStore.getState().activePhotoId
|
||||
)
|
||||
const closePreview = useCallback(() => {
|
||||
// Bypass the store action and write the restoration directly so
|
||||
// the snapshot ref is the single source of truth. Falls back to
|
||||
// the live activePhotoId if the ref was somehow never populated
|
||||
// (defensive — openPreview always sets activePhotoId before
|
||||
// PreviewView mounts).
|
||||
const id =
|
||||
openOriginRef.current ?? usePhotoStore.getState().activePhotoId
|
||||
usePhotoStore.setState({
|
||||
viewMode: 'grid',
|
||||
activePhotoId: id,
|
||||
rangeStartId: id,
|
||||
selectedPhotos: id ? [id] : [],
|
||||
previewOriginPhotoId: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 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
|
||||
|
||||
// Fallback fetch: when the preview is opened for a photo that isn't in
|
||||
// the timeline query result (e.g. clicked from the Map view, where the
|
||||
// active section's filter excludes it), look it up by id directly.
|
||||
// PhotoInfoPanel runs the same query under the same key, so they share
|
||||
// one cache entry — no extra request.
|
||||
const photoInListById = activePhotoId
|
||||
? photos.find((p) => p.id === activePhotoId)
|
||||
: undefined
|
||||
const { data: standalonePhoto } = useQuery<Photo>({
|
||||
queryKey: ['photo', activePhotoId],
|
||||
queryFn: () => photosApi.get(activePhotoId as string),
|
||||
enabled: !!activePhotoId && !photoInListById,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const currentPhoto: Photo | undefined =
|
||||
photoInListById ?? photos[safeIndex] ?? standalonePhoto
|
||||
|
||||
// Keep the latest photos array + active id in a ref so the keyboard
|
||||
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
|
||||
// hook can fire a closure that captured an older photos array (e.g.
|
||||
// the empty initial render before visiblePhotoIds was applied) and
|
||||
// arrow nav lands on the wrong photo or no-ops.
|
||||
const navRef = useRef({ photos, activePhotoId })
|
||||
navRef.current = { photos, activePhotoId }
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
const { photos: ps, activePhotoId: aid } = navRef.current
|
||||
if (ps.length === 0) return
|
||||
const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
|
||||
const safe = idx < 0 ? 0 : idx
|
||||
const next = Math.max(0, safe - 1)
|
||||
setActivePhoto(ps[next].id)
|
||||
}, [setActivePhoto])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
const { photos: ps, activePhotoId: aid } = navRef.current
|
||||
if (ps.length === 0) return
|
||||
const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
|
||||
const safe = idx < 0 ? 0 : idx
|
||||
const next = Math.min(ps.length - 1, safe + 1)
|
||||
setActivePhoto(ps[next].id)
|
||||
}, [setActivePhoto])
|
||||
|
||||
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
||||
// The handlers themselves are stable (refs internally) so the deps
|
||||
// array stays empty — useHotkeys won't have to re-bind on every render.
|
||||
//
|
||||
// Escape is intentionally NOT bound through useHotkeys here. The
|
||||
// grid-level Timeline component listens for Escape on `window` to
|
||||
// clear the current selection, and react-hotkeys-hook binds at
|
||||
// document level — so a single Esc keypress would land in BOTH
|
||||
// handlers. The grid handler would then wipe the selection we just
|
||||
// restored from the preview origin. Instead, escape is handled by
|
||||
// the dialog's onKeyDown below, which runs first (lower in the
|
||||
// bubble chain) and calls stopPropagation so the keypress never
|
||||
// reaches window.
|
||||
useHotkeys('left', goPrev, { preventDefault: true })
|
||||
useHotkeys('right', goNext, { preventDefault: true })
|
||||
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. Also intercepts
|
||||
// Escape and stops propagation before the keypress reaches the
|
||||
// window-level handler in Timeline (which would otherwise wipe the
|
||||
// selection we just restored to the entry photo).
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closePreview()
|
||||
return
|
||||
}
|
||||
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-[1000] 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-[1000] 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,170 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { useCardGridNav } from '../../hooks/useCardGridNav'
|
||||
import { Timeline } from '../timeline/Timeline'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
interface RatingGroup {
|
||||
rating: number
|
||||
label: string
|
||||
count: number
|
||||
representative: Photo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rated view — two states:
|
||||
* 1. Grid of rating-level cards (default) — arrow keys + Enter to browse
|
||||
* 2. Detail view showing a rating level's photos in the full Timeline — Esc to go back
|
||||
*/
|
||||
export function RatedView() {
|
||||
const { data: allPhotos = [], isLoading } = usePhotosQuery()
|
||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||
const setRatingMax = useFilterStore((s) => s.setRatingMax)
|
||||
const [selectedGroup, setSelectedGroup] = useState<RatingGroup | null>(null)
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const buckets = new Map<number, Photo[]>()
|
||||
|
||||
for (const photo of allPhotos) {
|
||||
if (photo.rating > 0) {
|
||||
const arr = buckets.get(photo.rating) ?? []
|
||||
arr.push(photo)
|
||||
buckets.set(photo.rating, arr)
|
||||
}
|
||||
}
|
||||
|
||||
// Highest rating first
|
||||
const result: RatingGroup[] = []
|
||||
for (let r = 5; r >= 1; r--) {
|
||||
const photos = buckets.get(r) ?? []
|
||||
if (photos.length === 0) continue
|
||||
result.push({
|
||||
rating: r,
|
||||
label: '★'.repeat(r),
|
||||
count: photos.length,
|
||||
representative: photos[0],
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [allPhotos])
|
||||
|
||||
const enterDetail = useCallback(
|
||||
(group: RatingGroup) => {
|
||||
setRatingMin(group.rating)
|
||||
setRatingMax(group.rating)
|
||||
setSelectedGroup(group)
|
||||
},
|
||||
[setRatingMin, setRatingMax]
|
||||
)
|
||||
|
||||
const exitDetail = useCallback(() => {
|
||||
// Restore the section preset: ratingMin=1 (all rated), no max
|
||||
setRatingMin(1)
|
||||
setRatingMax(0)
|
||||
setSelectedGroup(null)
|
||||
}, [setRatingMin, setRatingMax])
|
||||
|
||||
const { activeIndex, gridRef } = useCardGridNav({
|
||||
items: groups,
|
||||
inDetail: selectedGroup !== null,
|
||||
onEnter: enterDetail,
|
||||
onExit: exitDetail,
|
||||
})
|
||||
|
||||
if (selectedGroup) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<button
|
||||
onClick={exitDetail}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||
title="Back to ratings"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading ratings...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<Star className="h-12 w-12 opacity-40" />
|
||||
<p className="text-sm">No rated photos yet</p>
|
||||
<p className="max-w-xs text-center text-xs opacity-70">
|
||||
Rate photos with 1–5 stars and they will appear here grouped by
|
||||
rating.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-4 flex items-center gap-2 text-text-muted">
|
||||
<Star className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{groups.length} rating {groups.length === 1 ? 'level' : 'levels'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
|
||||
>
|
||||
{groups.map((group, i) => (
|
||||
<div
|
||||
key={group.rating}
|
||||
className={clsx(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
: 'border-border'
|
||||
)}
|
||||
onClick={() => enterDetail(group)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-surface-2">
|
||||
{group.representative ? (
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
|
||||
alt={group.label}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Star className="h-10 w-10 text-text-muted/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
{group.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-xs font-medium text-amber-400">{group.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,763 +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 { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
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
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
|
||||
/** Format a signed decimal degree value with the hemisphere letter, e.g.
|
||||
* ``48.12777° N``. Keeps the panel readable without dragging in a heavy
|
||||
* formatting lib. */
|
||||
function formatLatLon(value: number, axis: 'lat' | 'lon'): string {
|
||||
const abs = Math.abs(value).toFixed(5)
|
||||
const ref = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : (value >= 0 ? 'E' : 'W')
|
||||
return `${abs}° ${ref}`
|
||||
}
|
||||
|
||||
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'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
|
||||
// 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'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
|
||||
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-2.5 border-b border-border p-3">
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
{/* Filepath spans the full sidebar width — most paths are long
|
||||
* enough that the two-column grid above wraps them painfully.
|
||||
* Mono so each character lines up under the next, break-all
|
||||
* so we never overflow horizontally on a long basename. */}
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="text-text-muted">Path:</span>
|
||||
<p className="mt-0.5 break-all font-mono text-[11px] text-text" title={photo.filepath}>
|
||||
{photo.filepath || '—'}
|
||||
</p>
|
||||
</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')}
|
||||
>
|
||||
{photo.latitude != null && photo.longitude != null ? (
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 text-xs hover:underline"
|
||||
title="Open in OpenStreetMap"
|
||||
>
|
||||
<MapPin className="h-3 w-3 text-text-muted" />
|
||||
<span className="font-mono text-text">
|
||||
{formatLatLon(photo.latitude, 'lat')},{' '}
|
||||
{formatLatLon(photo.longitude, 'lon')}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-xs text-text-muted">No GPS data</div>
|
||||
)}
|
||||
</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-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<span>{title}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && <div className="px-3 pb-2.5">{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,138 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { photos as photosApi, type Tag } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { useCardGridNav } from '../../hooks/useCardGridNav'
|
||||
import { Timeline } from '../timeline/Timeline'
|
||||
|
||||
/**
|
||||
* Tags view — two states:
|
||||
* 1. Grid of tag cards (default) — arrow keys + Enter to browse
|
||||
* 2. Detail view showing a tag's photos in the full Timeline — Esc to go back
|
||||
*/
|
||||
export function TagsView() {
|
||||
const { data: allTags = [], isLoading } = useTagsQuery()
|
||||
const setTagIds = useFilterStore((s) => s.setTagIds)
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
|
||||
|
||||
// Exclude face_cluster tags (those live in PeopleView)
|
||||
const tags = useMemo(
|
||||
() => allTags.filter((t) => t.kind !== 'face_cluster'),
|
||||
[allTags]
|
||||
)
|
||||
|
||||
const enterDetail = useCallback(
|
||||
(tag: Tag) => {
|
||||
setTagIds([tag.id])
|
||||
setSelectedTag(tag)
|
||||
},
|
||||
[setTagIds]
|
||||
)
|
||||
|
||||
const exitDetail = useCallback(() => {
|
||||
setTagIds([])
|
||||
setSelectedTag(null)
|
||||
}, [setTagIds])
|
||||
|
||||
const { activeIndex, gridRef } = useCardGridNav({
|
||||
items: tags,
|
||||
inDetail: selectedTag !== null,
|
||||
onEnter: enterDetail,
|
||||
onExit: exitDetail,
|
||||
})
|
||||
|
||||
if (selectedTag) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<button
|
||||
onClick={exitDetail}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||
title="Back to tags"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading tags...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<TagIcon className="h-12 w-12 opacity-40" />
|
||||
<p className="text-sm">No tags yet</p>
|
||||
<p className="max-w-xs text-center text-xs opacity-70">
|
||||
Tags will appear here once photos are tagged — either manually or by
|
||||
the auto-tagger.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-4 flex items-center gap-2 text-text-muted">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{tags.length} {tags.length === 1 ? 'tag' : 'tags'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
|
||||
>
|
||||
{tags.map((tag, i) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={clsx(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
: 'border-border'
|
||||
)}
|
||||
onClick={() => enterDetail(tag)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-surface-2">
|
||||
{tag.representative_photo_id ? (
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
|
||||
alt={tag.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<TagIcon className="h-10 w-10 text-text-muted/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
{tag.photo_count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,337 +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'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
|
||||
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||
|
||||
// ── Thumbnail badge family ───────────────────────────────────────────────
|
||||
// Every ornament that overlays a thumbnail — here and in other views that
|
||||
// wrap PhotoThumbnail (e.g. DuplicatesView) — must compose these classes so
|
||||
// the set reads as one coherent system. Shape, height, typography are
|
||||
// fixed; variants set the fill + chiseled-pixel frame:
|
||||
// PRIMARY → user-affirmed state (selection, rating, active-heap)
|
||||
// NEUTRAL → informational metadata (duplicate flag, discard, file type, dims)
|
||||
// PICK → auto-suggested "best" in duplicate groups
|
||||
//
|
||||
// Frame is a single box-shadow stack rather than a Tailwind `ring` so the
|
||||
// 1px outline, the subtle inset top highlight, and the drop shadow all
|
||||
// live in one paint, and variants can tint the inset highlight to suit
|
||||
// their fill. Corners are `rounded-sm` (2px) to echo the pixel-art theme
|
||||
// used in the TopBar and ActiveHeapCard.
|
||||
export const THUMB_BADGE_BASE =
|
||||
'inline-flex h-5 items-center gap-1 rounded-sm px-1.5 text-[10px] font-semibold leading-none text-white'
|
||||
/** Square icon-only variant — compose alongside THUMB_BADGE_BASE. */
|
||||
export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0'
|
||||
/** Standard icon size for any lucide glyph inside a badge. */
|
||||
export const THUMB_BADGE_ICON = 'h-3 w-3'
|
||||
/** Chiseled-pixel frame: 1px outline + inset top highlight + drop shadow.
|
||||
* Shared by every "user-affirmed" variant so any swatch (primary, pick,
|
||||
* color label) reads as part of the same badge family. */
|
||||
const THUMB_BADGE_FRAME =
|
||||
'shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
|
||||
export const THUMB_BADGE_PRIMARY = `bg-primary ${THUMB_BADGE_FRAME}`
|
||||
export const THUMB_BADGE_NEUTRAL =
|
||||
'bg-black/75 backdrop-blur-sm shadow-[0_0_0_1px_rgba(255,255,255,0.12),inset_0_1px_0_rgba(255,255,255,0.08),0_1px_2px_rgba(0,0,0,0.55)]'
|
||||
export const THUMB_BADGE_PICK = `bg-pick ${THUMB_BADGE_FRAME}`
|
||||
|
||||
/** Tailwind bg-class for each color label, looked up at render time so the
|
||||
* classnames are statically present in the source for the JIT to scan. */
|
||||
const COLOR_LABEL_BG: Record<string, string> = Object.fromEntries(
|
||||
COLOR_LABEL_OPTIONS.map((o) => [o.value, o.className])
|
||||
)
|
||||
|
||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||
// the first hit on a freshly-scanned library returns 404 "not ready" until
|
||||
// the worker catches up. RAW postprocess can take several seconds per file
|
||||
// when the queue is deep, so the tail of the schedule is generous.
|
||||
const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000]
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
size: number
|
||||
/** When true, the cell stretches to fill its parent (100% width +
|
||||
* 100% height) and ignores `size` for the box dimensions. Used by
|
||||
* the Timeline grid where the parent is a CSS grid track of 1fr —
|
||||
* this is what guarantees the row fills the container without any
|
||||
* rounding gap on the right. The heap sidebar leaves this off so
|
||||
* thumbnails stay at the explicit `size`. */
|
||||
fill?: boolean
|
||||
isSelected: boolean
|
||||
/** True when the photo belongs to the currently active heap. */
|
||||
isInActiveHeap?: boolean
|
||||
/** Name of the active heap. When set + isInActiveHeap, the basket
|
||||
* badge expands into a name chip so the user knows which heap. */
|
||||
activeHeapName?: string | null
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
onDoubleClick?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function PhotoThumbnail({
|
||||
photo,
|
||||
size,
|
||||
fill = false,
|
||||
isSelected,
|
||||
isInActiveHeap = false,
|
||||
activeHeapName = null,
|
||||
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.
|
||||
//
|
||||
// The cell stretches to whatever width the parent grid track gives it
|
||||
// (via width:100% + aspect-ratio:1) so the timeline's CSS grid can hand
|
||||
// out 1fr columns and we never leave horizontal space unused. `size`
|
||||
// remains the *minimum* track width and the fallback when there's no
|
||||
// parent grid (e.g. heap thumbnails).
|
||||
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',
|
||||
// Two-tone hover ring: bright primary inner + dark offset so it
|
||||
// pops on light AND dark photos.
|
||||
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
|
||||
isSelected &&
|
||||
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
|
||||
!imageLoaded && 'bg-surface animate-pulse'
|
||||
)}
|
||||
style={
|
||||
fill
|
||||
? { width: '100%', height: '100%' }
|
||||
: { 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>
|
||||
)}
|
||||
|
||||
{/* ── Ornaments ────────────────────────────────────────────────────
|
||||
* All overlays compose the THUMB_BADGE_* classes so they share one
|
||||
* shape/size/ring family. Colour signals semantics:
|
||||
* PRIMARY → user-affirmed state (selection, rating, heap)
|
||||
* NEUTRAL → informational metadata (duplicate, discard, file type)
|
||||
* Corner ownership is fixed: TL=selection, TR=file-type,
|
||||
* BL=rating, BR=flags. This keeps badges from stacking or colliding. */}
|
||||
|
||||
{/* TL — selection */}
|
||||
{isSelected && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute left-1 top-1',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_SQUARE,
|
||||
THUMB_BADGE_PRIMARY
|
||||
)}
|
||||
>
|
||||
<Check className={THUMB_BADGE_ICON} strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BL — color label + rating. Color comes first (left of rating)
|
||||
* so the swatch reads as a "category dot" prefixing the stars. */}
|
||||
{(photo.color_label || photo.rating > 0) && (
|
||||
<div className="absolute bottom-1 left-1 flex items-center gap-1">
|
||||
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
|
||||
<div
|
||||
className={clsx(
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_SQUARE,
|
||||
COLOR_LABEL_BG[photo.color_label],
|
||||
THUMB_BADGE_FRAME
|
||||
)}
|
||||
title={`Color label: ${photo.color_label}`}
|
||||
/>
|
||||
)}
|
||||
{photo.rating > 0 && (
|
||||
<div
|
||||
className={clsx('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
|
||||
>
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BR — flags stack: heap (primary) · duplicate / discard (neutral).
|
||||
* Heap is the only user-state flag here so it gets primary; the
|
||||
* rest are metadata about the file, so they're neutral-dark. */}
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
||||
{isInActiveHeap && (
|
||||
<div
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY, 'max-w-[120px]')}
|
||||
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
|
||||
>
|
||||
<ShoppingBasket
|
||||
className={clsx(THUMB_BADGE_ICON, 'flex-shrink-0')}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
{activeHeapName && <span className="truncate">{activeHeapName}</span>}
|
||||
</div>
|
||||
)}
|
||||
{photo.is_duplicate && (
|
||||
<div
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
title="Duplicate (matches another photo's hash)"
|
||||
>
|
||||
<Copy className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
</div>
|
||||
)}
|
||||
{photo.is_discarded && (
|
||||
<div
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
title="Discarded"
|
||||
>
|
||||
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TR — file-type metadata (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={clsx('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
|
||||
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
import { useRef, useEffect, useMemo, useState, useCallback } 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.
|
||||
*
|
||||
* Two modes:
|
||||
* - sortBy is a date field: month buckets.
|
||||
* - otherwise: one un-headered stream.
|
||||
*
|
||||
* Tag, rating, and color grouping now live in their own dedicated views
|
||||
* (TagsView, RatedView, ColorsView) instead of being handled here.
|
||||
*/
|
||||
function buildItems(
|
||||
photos: Photo[],
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
sortBy: string,
|
||||
groupBy: string,
|
||||
): 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: rowHeight + GAP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Date grouping only applies when groupBy is explicitly 'date' and
|
||||
// the sort field is a date column. Other sections (tags, colors,
|
||||
// rated, people) reuse Timeline for their detail views and should
|
||||
// render a flat grid without month headers.
|
||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||
|
||||
if (!isDateSort || groupBy !== 'date') {
|
||||
// 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)
|
||||
// Sentinel placed inside the inner virtualizer wrapper at the exact
|
||||
// position rows will render. We measure THIS instead of parentRef,
|
||||
// because parentRef has padding and we'd otherwise have to subtract
|
||||
// it (and account for any scrollbar) — easy to get wrong by a pixel
|
||||
// and end up with a column count off by one.
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const {
|
||||
selectedPhotos,
|
||||
activePhotoId,
|
||||
selectPhoto,
|
||||
togglePhotoSelection,
|
||||
selectRange,
|
||||
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)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
|
||||
// Calculate number of columns + actual cell size based on container
|
||||
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
|
||||
// fill the remaining space, so we never leave a horizontal gap on
|
||||
// the right side of the grid.
|
||||
//
|
||||
// Column math: with N columns there are N-1 inter-cell gaps, so the
|
||||
// width needed is N*T + (N-1)*G. Solving for the largest N that fits
|
||||
// in the available width gives N = floor((available + G) / (T + G)).
|
||||
// The previous formula floor((available) / (T + G)) was off-by-one
|
||||
// and lost a whole column whenever the remainder almost fit.
|
||||
const { columns, cellSize } = useMemo(() => {
|
||||
if (containerWidth === 0) {
|
||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||
}
|
||||
// containerWidth here is the sentinel's actual rendered width — no
|
||||
// padding subtraction needed, the sentinel already lives inside the
|
||||
// padded scroll container.
|
||||
const available = containerWidth
|
||||
const cols = Math.max(
|
||||
1,
|
||||
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
|
||||
)
|
||||
// Exact float — no floor. cellSize × cols + (cols-1) × gap == available
|
||||
// by construction, so the row fills edge-to-edge without any
|
||||
// sub-pixel rounding gap.
|
||||
const cell = (available - (cols - 1) * GAP) / cols
|
||||
return { columns: cols, cellSize: cell }
|
||||
}, [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()
|
||||
|
||||
// Tracks the previous viewMode so the "preview just closed" scroll
|
||||
// effect (defined further down, after photoRows) only fires on the
|
||||
// actual transition rather than every items[] recomputation.
|
||||
const prevViewModeRef = useRef(viewMode)
|
||||
|
||||
// Auto-focus the first photo on initial grid load so arrow-key nav
|
||||
// works immediately without a pre-click. Only fires when there's no
|
||||
// current active photo — we never clobber the user's selection or
|
||||
// the one they restored by navigating back from preview.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'grid') return
|
||||
if (activePhotoId) return
|
||||
if (photos.length === 0) return
|
||||
selectPhoto(photos[0].id)
|
||||
}, [viewMode, activePhotoId, photos, selectPhoto])
|
||||
|
||||
// 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, activeHeap } = useActiveHeapMembers()
|
||||
const activeHeapName = activeHeap?.name ?? null
|
||||
|
||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||
// photos. Date headers appear only in the main timeline (groupBy='date').
|
||||
const items = useMemo(
|
||||
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
|
||||
[photos, columns, cellSize, 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])
|
||||
|
||||
// Virtual scrolling setup with per-item heights.
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => items[index]?.height ?? cellSize,
|
||||
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 the sentinel's actual rendered width on mount, window
|
||||
// resize, and any layout change driven by the sidebar collapse /
|
||||
// right panel toggle. ResizeObserver picks up everything window
|
||||
// resize misses (sidebar collapse doesn't fire window resize).
|
||||
//
|
||||
// Uses a callback ref (not useRef + useEffect) because Timeline
|
||||
// early-returns a loading/empty state before the sentinel exists,
|
||||
// so a mount-only effect would see a null ref and never install
|
||||
// the observer. The callback ref fires whenever the sentinel
|
||||
// actually attaches, which is the moment we can measure it.
|
||||
const roRef = useRef<ResizeObserver | null>(null)
|
||||
const measureElRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
measureElRef.current = el
|
||||
if (!el) return
|
||||
const measure = () => setContainerWidth(el.clientWidth)
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
roRef.current = ro
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const el = measureElRef.current
|
||||
if (el) setContainerWidth(el.clientWidth)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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]
|
||||
)
|
||||
|
||||
// Parallel array: photoRows[i] corresponds to items[photoRowItemIndex[i]].
|
||||
// Lets keyboard nav jump the virtualizer to the destination row even when
|
||||
// it hasn't been rendered yet (beyond the overscan window).
|
||||
const photoRowItemIndex = useMemo(() => {
|
||||
const map: number[] = []
|
||||
items.forEach((it, idx) => {
|
||||
if (it.type === 'row') map.push(idx)
|
||||
})
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
// After the preview closes, scroll the photo it was originally
|
||||
// opened on back into view. The store's closePreview already
|
||||
// restored activePhotoId to that origin id; we just need to make
|
||||
// sure it's actually visible in the scroll viewport. Guarded by
|
||||
// prevViewModeRef so this only fires on the actual preview→grid
|
||||
// transition, not every time photoRows recomputes.
|
||||
useEffect(() => {
|
||||
const prev = prevViewModeRef.current
|
||||
prevViewModeRef.current = viewMode
|
||||
if (prev !== 'preview' || viewMode !== 'grid') return
|
||||
if (!activePhotoId) return
|
||||
let rowIdx = -1
|
||||
for (let r = 0; r < photoRows.length; r++) {
|
||||
if (photoRows[r].cells.some((c) => c.photo.id === activePhotoId)) {
|
||||
rowIdx = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if (rowIdx < 0) return
|
||||
const itemIdx = photoRowItemIndex[rowIdx]
|
||||
const scrollEl = parentRef.current
|
||||
if (itemIdx === undefined || !scrollEl) return
|
||||
let rowTop = 0
|
||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
||||
const rowHeight = items[itemIdx].height
|
||||
const viewTop = scrollEl.scrollTop
|
||||
const viewBottom = viewTop + scrollEl.clientHeight
|
||||
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
|
||||
// Center the row in the viewport — the user is returning to a
|
||||
// specific photo, not resuming a scroll, so context above and
|
||||
// below is what they want.
|
||||
const target = Math.max(
|
||||
0,
|
||||
rowTop - scrollEl.clientHeight / 2 + rowHeight / 2
|
||||
)
|
||||
scrollEl.scrollTo({ top: target })
|
||||
}, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])
|
||||
|
||||
// Flat visible-order id sequence — exactly the order the user reads
|
||||
// off the grid (top-to-bottom, left-to-right within each row).
|
||||
// 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.
|
||||
const visibleSequence = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
for (const row of photoRows) {
|
||||
for (const cell of row.cells) {
|
||||
ids.push(cell.photo.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [photoRows])
|
||||
|
||||
// Publish to the photo store so PreviewView's arrow nav and filmstrip
|
||||
// can walk the same order even when opened from a non-click path
|
||||
// (e.g. the global Space hotkey).
|
||||
useEffect(() => {
|
||||
setVisiblePhotoIds(visibleSequence)
|
||||
}, [visibleSequence, 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.
|
||||
//
|
||||
// Inert in preview mode — PreviewView mounts its own arrow handlers,
|
||||
// and a window-level grid handler firing alongside them used to race
|
||||
// against PreviewView's setActivePhoto, landing the user on the wrong
|
||||
// photo. The grid handler stays attached so it can re-engage the
|
||||
// moment the user closes preview.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'grid') return
|
||||
// The duplicates section mounts its own grouped view (DuplicatesView)
|
||||
// with its own keyboard nav — bail out so we don't double-handle
|
||||
// arrow keys and try to navigate against a photoRows snapshot that
|
||||
// doesn't match what the user actually sees on screen.
|
||||
if (currentSection === 'duplicates') return
|
||||
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.photo.id)
|
||||
} else {
|
||||
selectPhoto(dest.photo.id)
|
||||
}
|
||||
// Bring the destination row into view if it's off-screen, leaving
|
||||
// a "peek" margin so the next row above/below stays partly visible
|
||||
// — cues the user that there's more content in the scroll direction.
|
||||
// In-viewport moves are a no-op, so same-row arrow presses don't
|
||||
// jitter the scroll position.
|
||||
const itemIdx = photoRowItemIndex[nextRow]
|
||||
const scrollEl = parentRef.current
|
||||
if (itemIdx !== undefined && scrollEl) {
|
||||
// Sum item heights up to itemIdx to get this row's offset in the
|
||||
// virtualizer's coordinate space. Cheap enough at O(items) and
|
||||
// avoids reaching into virtualizer.measurementsCache internals.
|
||||
let rowTop = 0
|
||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
||||
const rowHeight = items[itemIdx].height
|
||||
const peek = Math.round(cellSize * 0.35)
|
||||
const viewTop = scrollEl.scrollTop
|
||||
const viewBottom = viewTop + scrollEl.clientHeight
|
||||
if (rowTop - peek < viewTop) {
|
||||
// Destination is above (or flush with) the viewport top. Leave
|
||||
// `peek` pixels of the previous row visible above it.
|
||||
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
|
||||
} else if (rowTop + rowHeight + peek > viewBottom) {
|
||||
// Destination is below the viewport bottom. Leave `peek` pixels
|
||||
// of the next row visible below it.
|
||||
scrollEl.scrollTo({
|
||||
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!selectedPhotos.includes(photo.id)) {
|
||||
togglePhotoSelection(photo.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
clearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection])
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{/* Width sentinel — a 1px-tall normal-flow div that takes the
|
||||
* full width of the inner virtualizer wrapper, which is the
|
||||
* exact width rows render at. clientWidth on this is what we
|
||||
* base the column count on, sidestepping any padding /
|
||||
* scrollbar mismatch the parentRef-based measurement is
|
||||
* vulnerable to. ResizeObserver doesn't reliably fire on
|
||||
* zero-area absolute elements, so 1px tall + relative flow. */}
|
||||
<div
|
||||
ref={widthSentinelRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 1,
|
||||
marginBottom: -1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{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">
|
||||
{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
|
||||
style={{
|
||||
// Fixed-size grid: every track is exactly cellSize
|
||||
// wide and the row is exactly cellSize tall, so
|
||||
// cells are guaranteed square no matter what CSS
|
||||
// the cell contents bring along. cellSize was
|
||||
// already computed from `available / cols` so the
|
||||
// sum cols*cellSize + (cols-1)*gap equals the
|
||||
// container width to within sub-pixel rounding.
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
|
||||
gridAutoRows: `${cellSize}px`,
|
||||
gap: `${GAP}px`,
|
||||
}}
|
||||
>
|
||||
{item.cells.map(({ photo }) => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
size={cellSize}
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(photo.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
activeHeapName={activeHeapName}
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
selectRange(photo.id)
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
togglePhotoSelection(photo.id)
|
||||
} else {
|
||||
selectPhoto(photo.id)
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
|
||||
/>
|
||||
))}
|
||||
</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' },
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user