Compare commits
315 Commits
78e12e8309
...
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 | |||
| a4f64fad58 | |||
| 4bc6dc1dc8 | |||
| fa9b21856f | |||
| f48e099bd2 | |||
| 40d570f2c2 | |||
| 5f3fa5240e | |||
| aba061dd43 | |||
| 229611b4c3 | |||
| db20cbb7d8 | |||
| 17a69a271e | |||
| 7558aeb5e6 | |||
| 2a6661f779 | |||
| 29177f0c1a | |||
| ad007e4cd4 | |||
| 1ebc4bfe73 | |||
| 842a4fc864 | |||
| 649437dc85 | |||
| b1c2bdf7f0 | |||
| 9282a5c734 | |||
| dea04ceed9 | |||
| f01b5ed77e | |||
| b2ebf401bb | |||
| 57788f60c5 | |||
| 7cf546af7a | |||
| 9c9f5bd899 | |||
| 1c6383e5a0 | |||
| c973ca0443 | |||
| 870e7dd3d3 | |||
| 30cbcb8cd1 | |||
| 03b99a40b0 | |||
| b993a188d3 | |||
| 367d1277b8 | |||
| 7d1149aabd | |||
| 8f25c58b74 | |||
| 12a616d4b5 | |||
| 2e7158a5bb | |||
| 938c5dce68 | |||
| 6d8e92940a | |||
| f4c51f6f92 | |||
| 64b84e2069 | |||
| 733c16bf82 | |||
| e51b93d59e | |||
| d6c667ae78 | |||
|
|
a4b1802657 | ||
|
|
ce25a4460e | ||
|
|
872be4e0cf | ||
|
|
d27ec1af2e | ||
|
|
697343646a | ||
|
|
3df8add3b6 | ||
|
|
42250aa16e | ||
| 9ed577f40c | |||
| 6fa00f4b37 | |||
| e49f64659e | |||
| 9317730885 | |||
| eb16564b84 | |||
| 7bb03be51a | |||
| 123c60ed2c | |||
| e2ee9b691c | |||
| 57f510ad3d | |||
| 26424469db | |||
| ec4a14976d | |||
| e7d62c29e1 | |||
| 8e00dd40f0 | |||
| 8db4242503 | |||
| dea19e5c23 | |||
| 5a0f9ff592 | |||
| a56062d353 | |||
| 7a0f738aa8 | |||
| d7f953d0a9 | |||
| 30e0900e49 | |||
| c01c3b02ce | |||
| 4e5b2cabf6 | |||
| f05ae77ef0 | |||
| bd904aca36 | |||
| 696477eefd | |||
| fc63b1f69d | |||
| ac5b18b60c | |||
| a5b4054a71 | |||
| a55839d9a2 | |||
| 749e836617 | |||
| 07b9660e92 | |||
| b870084be0 | |||
| 3a03a56db2 | |||
| 1c428dda8b | |||
| 2f1e9033ae | |||
| 522228fb79 | |||
| 5e10b12b13 | |||
| 8fd8bfe3de | |||
| 8413b112ee | |||
| b4a2241bd9 | |||
| db37be902e | |||
| 6917e618e5 | |||
| 6985026106 | |||
| 2d37fba211 | |||
| 914eb58ac5 | |||
| bb7c2b12d6 | |||
| bed817d274 | |||
| bc1e63095c | |||
| 63383ecf1c | |||
| a8750afef0 | |||
| 320107841b | |||
| 204d2bf2a8 | |||
| a0c41e38d3 | |||
| 7d33e1688a | |||
| 16481730b7 | |||
| 066acb64ec | |||
| 485b60ff20 | |||
| 9729391dcc | |||
| 7c003bc92e | |||
| cf7c72d437 | |||
| 7dcfa8f30d | |||
| 66ffd94c48 | |||
| 61486a503e | |||
| 5f11698907 | |||
| ee6b49952e | |||
| 324cc0298b | |||
| 351ccd7bb4 | |||
| 02fb1cd508 | |||
| ebae775f3f | |||
| 322969c938 | |||
| c7d2cc47e1 | |||
| 997e11db78 | |||
| 2679214cb9 | |||
| ce2cda0565 | |||
| f7bf22db29 | |||
| 3e322576f2 | |||
| 6e6672f225 | |||
| d2155d9dd2 | |||
| 892e8e1da4 | |||
| 9089ad2f61 | |||
| f4fc15101e | |||
| 1096854553 | |||
| 72d301a9c7 |
23
.env
23
.env
@@ -1,23 +0,0 @@
|
|||||||
# Environment variables for Mulita
|
|
||||||
|
|
||||||
# Photo directories to mount (can be multiple paths separated by colon)
|
|
||||||
# Example: /path/to/photos1:/path/to/photos2
|
|
||||||
PHOTO_DIRS=./photos
|
|
||||||
|
|
||||||
# Redis configuration
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Database URL
|
|
||||||
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db
|
|
||||||
|
|
||||||
# Celery configuration
|
|
||||||
CELERY_BROKER_URL=redis://localhost:6379
|
|
||||||
CELERY_RESULT_BACKEND=redis://localhost:6379
|
|
||||||
CELERYD_CONCURRENCY=4
|
|
||||||
|
|
||||||
# API settings
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
API_PORT=8000
|
|
||||||
|
|
||||||
# Frontend settings
|
|
||||||
VITE_API_URL=http://localhost:8000
|
|
||||||
70
.env.example
Normal file
70
.env.example
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Example environment file. Copy to `.env` and adjust.
|
||||||
|
#
|
||||||
|
# podman-compose --env-file .env \
|
||||||
|
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||||
|
|
||||||
|
|
||||||
|
# ── REQUIRED ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PP_LOG_LEVEL=info
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -34,6 +34,7 @@ dist-ssr/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
@@ -60,9 +61,18 @@ build/
|
|||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
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 (for development)
|
||||||
/photos/
|
/photos/
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
/thumbs/
|
/thumbs/
|
||||||
/trash/
|
|
||||||
230
README.md
230
README.md
@@ -1,128 +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.
|
Self-hosted photo management built on top of [PhotoPrism][pp]. A SvelteKit
|
||||||
|
frontend ([`web/`](web/)) plus a small Go service ([`sidecar/`](sidecar/))
|
||||||
## Features
|
fill in the keyboard-driven UI and the file/folder/mark endpoints
|
||||||
|
PhotoPrism's REST API does not expose. PhotoPrism itself handles
|
||||||
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
|
indexing, originals, thumbnails, and the database; we never re-implement
|
||||||
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
|
those.
|
||||||
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and search
|
|
||||||
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
|
|
||||||
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
|
|
||||||
- **Heaps**: Temporary collections for organizing photos
|
|
||||||
- **Tags & Ratings**: Organize with tags, star ratings, and color labels
|
|
||||||
- **Dark Mode**: Photography-optimized dark interface
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- Python 3.12 with FastAPI
|
|
||||||
- SQLite with SQLAlchemy (async)
|
|
||||||
- Celery + Redis for background tasks
|
|
||||||
- pyvips for fast thumbnail generation
|
|
||||||
- ExifTool for metadata extraction
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- React 18 with TypeScript
|
|
||||||
- Vite for fast development
|
|
||||||
- TanStack Query for data fetching
|
|
||||||
- TanStack Virtual for virtualized scrolling
|
|
||||||
- Tailwind CSS for styling
|
|
||||||
- Zustand for state management
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Docker and Docker Compose
|
|
||||||
- Photo directories to mount
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd muleimage
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Configure your photo directories in `.env`:
|
|
||||||
```bash
|
|
||||||
# Edit .env file
|
|
||||||
PHOTO_DIRS=/path/to/your/photos
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the application:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Access the application at `http://localhost:3000`
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The application consists of 5 Docker services:
|
```text
|
||||||
|
┌──────────────────┐ /api/v1/* ┌──────────────┐
|
||||||
- **frontend**: React SPA served by Nginx
|
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
|
||||||
- **backend**: FastAPI REST API
|
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
|
||||||
- **worker**: Celery workers for background tasks
|
│ │ ─────────┐ └──────────────┘
|
||||||
- **redis**: Message broker for Celery
|
└──────────────────┘ ▼
|
||||||
- **db**: SQLite database (file-based)
|
┌──────────────┐
|
||||||
|
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
|
||||||
## Keyboard Shortcuts
|
│ :8000 │ ──▶ originals FS (rename / folders / dups)
|
||||||
|
└──────────────┘
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `←` `→` `↑` `↓` | Navigate photos |
|
|
||||||
| `Space` | Quick preview |
|
|
||||||
| `Enter` | Open loupe view |
|
|
||||||
| `P` | Pick photo |
|
|
||||||
| `X` | Reject photo |
|
|
||||||
| `1-5` | Set star rating |
|
|
||||||
| `Tab` | Toggle left sidebar |
|
|
||||||
| `I` | Toggle metadata panel |
|
|
||||||
| `G` | Grid view |
|
|
||||||
| `E` | Loupe view |
|
|
||||||
| `Delete` | Move to trash |
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Backend Development
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```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 install
|
||||||
npm run dev
|
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
|
## Configuration
|
||||||
|
|
||||||
Edit `mulita.yml` to configure:
|
All knobs live in [`.env.example`](.env.example). The required ones:
|
||||||
- Source photo directories
|
|
||||||
- Thumbnail sizes and quality
|
|
||||||
- Scanner settings
|
|
||||||
- Performance tuning
|
|
||||||
|
|
||||||
## Performance
|
| 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`). |
|
||||||
|
|
||||||
- Handles 100,000+ photos efficiently
|
Sidecar-specific env (DB DSN, `USER_BASEPATHS`, etc.) is documented in
|
||||||
- Virtual scrolling for smooth timeline navigation
|
[`sidecar/README.md`](sidecar/README.md).
|
||||||
- Thumbnail generation at 10+ photos/second
|
|
||||||
- SQLite FTS5 for fast full-text search
|
|
||||||
|
|
||||||
## Future Features (Phase 2)
|
## Read-only libraries
|
||||||
|
|
||||||
- AI-powered scene classification
|
The default originals mount is `:rw` because file operations (rename,
|
||||||
- Face detection and clustering
|
folder mutations, duplicate archive, heap convert) need to mutate the
|
||||||
- Smart albums
|
filesystem. To run against a read-only archive, set
|
||||||
- Duplicate detection
|
`PP_ORIGINALS_MODE=ro` in `.env`. Browsing, marks, ratings, and color
|
||||||
- Export presets
|
labels still work; the following sidecar endpoints return an OS error:
|
||||||
- Multi-user support
|
|
||||||
|
|
||||||
## License
|
- `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`
|
||||||
|
|
||||||
MIT
|
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
||||||
|
`PP_READONLY` and gates its own backwrite / import paths.
|
||||||
|
|
||||||
|
## 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/trash /app/config
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"""
|
|
||||||
Application configuration using Pydantic Settings
|
|
||||||
"""
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Optional
|
|
||||||
import os
|
|
||||||
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 SourceRoot(BaseModel):
|
|
||||||
"""Source root directory configuration"""
|
|
||||||
name: str
|
|
||||||
path: str
|
|
||||||
|
|
||||||
class TrashSettings(BaseModel):
|
|
||||||
"""Trash settings"""
|
|
||||||
path: str = "/data/trash"
|
|
||||||
auto_empty_days: Optional[int] = 30
|
|
||||||
|
|
||||||
class PerformanceSettings(BaseModel):
|
|
||||||
"""Performance tuning settings"""
|
|
||||||
max_concurrent_thumbnails: int = 10
|
|
||||||
cache_ttl: int = 3600
|
|
||||||
db_pool_size: int = 20
|
|
||||||
db_pool_recycle: int = 3600
|
|
||||||
|
|
||||||
class MulitaConfig(BaseModel):
|
|
||||||
"""Main configuration from YAML file"""
|
|
||||||
source_roots: List[SourceRoot] = []
|
|
||||||
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
|
||||||
scanner: ScannerSettings = ScannerSettings()
|
|
||||||
trash: TrashSettings = TrashSettings()
|
|
||||||
performance: PerformanceSettings = PerformanceSettings()
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Application settings"""
|
|
||||||
# Database
|
|
||||||
database_url: str = Field(
|
|
||||||
default="sqlite+aiosqlite:///data/db/mulita.db",
|
|
||||||
env="DATABASE_URL"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
redis_url: str = Field(
|
|
||||||
default="redis://localhost:6379",
|
|
||||||
env="REDIS_URL"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Celery
|
|
||||||
celery_broker_url: str = Field(
|
|
||||||
default="redis://localhost:6379",
|
|
||||||
env="CELERY_BROKER_URL"
|
|
||||||
)
|
|
||||||
celery_result_backend: str = Field(
|
|
||||||
default="redis://localhost:6379",
|
|
||||||
env="CELERY_RESULT_BACKEND"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Photo directories
|
|
||||||
photo_dirs: str = Field(
|
|
||||||
default="/photos",
|
|
||||||
env="PHOTO_DIRS"
|
|
||||||
)
|
|
||||||
|
|
||||||
# API settings
|
|
||||||
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
|
||||||
api_port: int = Field(default=8000, env="API_PORT")
|
|
||||||
|
|
||||||
# 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 trash(self) -> TrashSettings:
|
|
||||||
return self.config.trash
|
|
||||||
|
|
||||||
@property
|
|
||||||
def performance(self) -> PerformanceSettings:
|
|
||||||
return self.config.performance
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_roots(self) -> List[SourceRoot]:
|
|
||||||
return self.config.source_roots
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
case_sensitive = False
|
|
||||||
|
|
||||||
# Global settings instance
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""
|
|
||||||
Database configuration and session management
|
|
||||||
"""
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
||||||
from sqlalchemy.orm import declarative_base
|
|
||||||
from sqlalchemy import event, text
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Create database directory if it doesn't exist
|
|
||||||
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create async engine
|
|
||||||
# SQLite doesn't support pool configuration
|
|
||||||
if "sqlite" in settings.database_url:
|
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
|
||||||
echo=False, # Set to True for SQL debugging
|
|
||||||
connect_args={
|
|
||||||
"check_same_thread": False, # SQLite specific
|
|
||||||
"timeout": 30
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
|
||||||
echo=False, # Set to True for SQL debugging
|
|
||||||
pool_size=settings.performance.db_pool_size,
|
|
||||||
pool_recycle=settings.performance.db_pool_recycle
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create async session factory
|
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
|
||||||
engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Base class for models
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
|
||||||
"""Dependency to get database session"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
async def init_db():
|
|
||||||
"""Initialize database, create tables if they don't exist"""
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
# Import all models to register them with Base
|
|
||||||
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
|
|
||||||
|
|
||||||
# Create all tables
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
# Enable WAL mode for SQLite (better concurrency)
|
|
||||||
if "sqlite" in settings.database_url:
|
|
||||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
|
||||||
await conn.execute(text("PRAGMA synchronous=NORMAL"))
|
|
||||||
await conn.execute(text("PRAGMA cache_size=10000"))
|
|
||||||
await conn.execute(text("PRAGMA temp_store=MEMORY"))
|
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
|
||||||
|
|
||||||
async def create_fts_table():
|
|
||||||
"""Create Full-Text Search table for SQLite"""
|
|
||||||
if "sqlite" in settings.database_url:
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
# Create FTS5 virtual table for full-text search
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
|
|
||||||
photo_id UNINDEXED,
|
|
||||||
filename,
|
|
||||||
user_title,
|
|
||||||
user_notes,
|
|
||||||
exif_text,
|
|
||||||
tokenize='unicode61'
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
logger.info("FTS5 table created successfully")
|
|
||||||
@@ -1,82 +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, trash, library
|
|
||||||
from app.services.scanner import start_initial_scan
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
# 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
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
|
|
||||||
allow_credentials=True,
|
|
||||||
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(trash.router, prefix="/api/v1/trash", tags=["trash"])
|
|
||||||
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
"""Root endpoint"""
|
|
||||||
return {
|
|
||||||
"name": "Mulita Photo Management API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"status": "running"
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
"""Health check endpoint for Docker"""
|
|
||||||
return {"status": "healthy"}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""
|
|
||||||
Database models for Mulita
|
|
||||||
"""
|
|
||||||
from app.models.photos import Photo
|
|
||||||
from app.models.folders import Folder, SourceRoot
|
|
||||||
from app.models.tags import Tag, PhotoTag
|
|
||||||
from app.models.heaps import Heap, HeapPhoto
|
|
||||||
from app.models.embeddings import Embedding
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'Photo',
|
|
||||||
'Folder',
|
|
||||||
'SourceRoot',
|
|
||||||
'Tag',
|
|
||||||
'PhotoTag',
|
|
||||||
'Heap',
|
|
||||||
'HeapPhoto',
|
|
||||||
'Embedding'
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""
|
|
||||||
Embedding model definition (placeholder for AI features)
|
|
||||||
"""
|
|
||||||
from sqlalchemy import Column, String, ForeignKey, LargeBinary
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
class Embedding(Base):
|
|
||||||
"""
|
|
||||||
Placeholder table for future AI embeddings (CLIP, face recognition, etc.)
|
|
||||||
"""
|
|
||||||
__tablename__ = 'embeddings'
|
|
||||||
|
|
||||||
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
|
|
||||||
model = Column(String) # e.g., 'clip-vit-b32', 'face-recognition', etc.
|
|
||||||
vector = Column(LargeBinary) # raw float32 bytes for embedding vector
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""
|
|
||||||
Folder and SourceRoot model definitions
|
|
||||||
"""
|
|
||||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Index
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
class SourceRoot(Base):
|
|
||||||
__tablename__ = 'source_roots'
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
name = Column(String, nullable=False)
|
|
||||||
path = Column(String, unique=True, nullable=False)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
added_at = Column(DateTime, server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
folders = relationship("Folder", back_populates="source_root")
|
|
||||||
|
|
||||||
class Folder(Base):
|
|
||||||
__tablename__ = 'folders'
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
name = Column(String, nullable=False)
|
|
||||||
path = Column(String, unique=True, nullable=False)
|
|
||||||
parent_id = Column(String, ForeignKey('folders.id'))
|
|
||||||
source_root_id = Column(String, ForeignKey('source_roots.id'))
|
|
||||||
photo_count = Column(Integer, default=0)
|
|
||||||
last_scanned = Column(DateTime)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
source_root = relationship("SourceRoot", back_populates="folders")
|
|
||||||
photos = relationship("Photo", backref="folder")
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_folders_path', 'path'),
|
|
||||||
Index('ix_folders_parent_id', 'parent_id'),
|
|
||||||
Index('ix_folders_source_root_id', 'source_root_id'),
|
|
||||||
)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""
|
|
||||||
Heap model definitions
|
|
||||||
"""
|
|
||||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Table, Index
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
# Association table for many-to-many relationship with additional fields
|
|
||||||
heap_photos = Table(
|
|
||||||
'heap_photos',
|
|
||||||
Base.metadata,
|
|
||||||
Column('heap_id', String, ForeignKey('heaps.id', ondelete='CASCADE'), primary_key=True),
|
|
||||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
|
||||||
Column('added_at', DateTime, server_default=func.now()),
|
|
||||||
Column('sort_order', Integer, default=0),
|
|
||||||
Index('ix_heap_photos_heap_id', 'heap_id'),
|
|
||||||
Index('ix_heap_photos_photo_id', 'photo_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Heap(Base):
|
|
||||||
__tablename__ = 'heaps'
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
name = Column(String, nullable=False)
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
|
||||||
updated_at = Column(DateTime, onupdate=func.now())
|
|
||||||
is_active = Column(Boolean, default=False) # For active heap feature
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
|
|
||||||
|
|
||||||
class HeapPhoto:
|
|
||||||
"""Helper class for heap-photo associations (not a table model)"""
|
|
||||||
pass
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""
|
|
||||||
Photo model definition
|
|
||||||
"""
|
|
||||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Index
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from datetime import datetime
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
class Photo(Base):
|
|
||||||
__tablename__ = 'photos'
|
|
||||||
|
|
||||||
# Primary key
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
|
|
||||||
# File information
|
|
||||||
filepath = Column(String, unique=True, nullable=False)
|
|
||||||
filename = Column(String, nullable=False)
|
|
||||||
folder_id = Column(String, ForeignKey('folders.id'))
|
|
||||||
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
|
|
||||||
|
|
||||||
# Media information
|
|
||||||
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'
|
|
||||||
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
|
|
||||||
width = Column(Integer)
|
|
||||||
height = Column(Integer)
|
|
||||||
file_size = Column(Integer)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
taken_at = Column(DateTime) # from EXIF DateTimeOriginal, fallback to file mtime
|
|
||||||
taken_at_source = Column(String) # 'exif' | 'filesystem' | 'manual'
|
|
||||||
added_at = Column(DateTime, server_default=func.now())
|
|
||||||
updated_at = Column(DateTime, onupdate=func.now())
|
|
||||||
|
|
||||||
# Trash status
|
|
||||||
is_trashed = Column(Boolean, default=False)
|
|
||||||
trashed_at = Column(DateTime)
|
|
||||||
|
|
||||||
# Thumbnail paths
|
|
||||||
thumb_small = Column(String) # path to 240px thumb
|
|
||||||
thumb_medium = Column(String) # path to 640px thumb
|
|
||||||
thumb_large = Column(String) # path to 1280px thumb
|
|
||||||
|
|
||||||
# Processing status
|
|
||||||
processing_status = Column(String, default='pending') # 'pending' | 'processing' | 'completed' | 'failed'
|
|
||||||
processing_error = Column(Text)
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
exif_json = Column(Text) # full EXIF/XMP blob as JSON
|
|
||||||
|
|
||||||
# User-editable fields
|
|
||||||
user_title = Column(String)
|
|
||||||
user_notes = Column(Text)
|
|
||||||
rating = Column(Integer, default=0) # 0-5 stars
|
|
||||||
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
|
||||||
is_picked = Column(Boolean, default=False)
|
|
||||||
is_rejected = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Duplicate detection
|
|
||||||
is_duplicate = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Live photo support
|
|
||||||
live_photo_video_id = Column(String, ForeignKey('photos.id'))
|
|
||||||
|
|
||||||
# Indexes for performance
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_photos_taken_at', 'taken_at'),
|
|
||||||
Index('ix_photos_folder_id', 'folder_id'),
|
|
||||||
Index('ix_photos_is_trashed', 'is_trashed'),
|
|
||||||
Index('ix_photos_rating', 'rating'),
|
|
||||||
Index('ix_photos_color_label', 'color_label'),
|
|
||||||
Index('ix_photos_media_type', 'media_type'),
|
|
||||||
Index('ix_photos_processing_status', 'processing_status'),
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""
|
|
||||||
Tag model definitions
|
|
||||||
"""
|
|
||||||
from sqlalchemy import Column, String, ForeignKey, Table, Index
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
# Association table for many-to-many relationship
|
|
||||||
photo_tags = Table(
|
|
||||||
'photo_tags',
|
|
||||||
Base.metadata,
|
|
||||||
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
|
|
||||||
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
|
|
||||||
Index('ix_photo_tags_photo_id', 'photo_id'),
|
|
||||||
Index('ix_photo_tags_tag_id', 'tag_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Tag(Base):
|
|
||||||
__tablename__ = 'tags'
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
name = Column(String, unique=True, nullable=False, index=True)
|
|
||||||
color = Column(String) # Hex color code for UI display
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
photos = relationship("Photo", secondary=photo_tags, backref="tags")
|
|
||||||
|
|
||||||
class PhotoTag:
|
|
||||||
"""Helper class for photo-tag associations (not a table model)"""
|
|
||||||
pass
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"""
|
|
||||||
Folders API router
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from typing import List, Optional
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Folder, SourceRoot
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
class FolderCreate(BaseModel):
|
|
||||||
path: str
|
|
||||||
recursive: bool = True
|
|
||||||
watch: bool = False
|
|
||||||
|
|
||||||
class FolderResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
path: str
|
|
||||||
photo_count: int
|
|
||||||
|
|
||||||
@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.post("")
|
|
||||||
async def create_folder(folder: FolderCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Add a new source folder"""
|
|
||||||
# Check if path exists
|
|
||||||
if not os.path.exists(folder.path):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {folder.path}")
|
|
||||||
|
|
||||||
# Check if path is already added
|
|
||||||
result = await db.execute(select(SourceRoot).where(SourceRoot.path == folder.path))
|
|
||||||
existing = result.scalar_one_or_none()
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=400, detail="Path already added as source folder")
|
|
||||||
|
|
||||||
# Create source root
|
|
||||||
source_root = SourceRoot(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name=os.path.basename(folder.path),
|
|
||||||
path=folder.path
|
|
||||||
)
|
|
||||||
db.add(source_root)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Automatically trigger a scan for the new folder
|
|
||||||
from app.tasks.celery import celery_app
|
|
||||||
celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": source_root.id,
|
|
||||||
"name": source_root.name,
|
|
||||||
"path": source_root.path,
|
|
||||||
"photo_count": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,27 +0,0 @@
|
|||||||
"""
|
|
||||||
Heaps API router
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Heap
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""List all heaps"""
|
|
||||||
result = await db.execute(select(Heap))
|
|
||||||
heaps = result.scalars().all()
|
|
||||||
return heaps
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def create_heap(name: str, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Create a new heap"""
|
|
||||||
heap = Heap(name=name)
|
|
||||||
db.add(heap)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(heap)
|
|
||||||
return heap
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""
|
|
||||||
Library API router for stats and scanning
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Photo
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Get library statistics"""
|
|
||||||
# Count total photos
|
|
||||||
total_photos = await db.execute(
|
|
||||||
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
|
|
||||||
)
|
|
||||||
photo_count = total_photos.scalar()
|
|
||||||
|
|
||||||
# Count total videos
|
|
||||||
total_videos = await db.execute(
|
|
||||||
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
|
||||||
)
|
|
||||||
video_count = total_videos.scalar()
|
|
||||||
|
|
||||||
# Calculate total size
|
|
||||||
total_size = await db.execute(
|
|
||||||
select(func.sum(Photo.file_size))
|
|
||||||
)
|
|
||||||
size = total_size.scalar() or 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_photos": photo_count,
|
|
||||||
"total_videos": video_count,
|
|
||||||
"total_size": size,
|
|
||||||
"total_size_gb": round(size / (1024**3), 2) if size else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/scan")
|
|
||||||
async def trigger_scan():
|
|
||||||
"""Trigger full library re-scan"""
|
|
||||||
from app.tasks.scan import scan_all_source_roots
|
|
||||||
|
|
||||||
scan_all_source_roots.delay()
|
|
||||||
|
|
||||||
return {"status": "success", "message": "Library scan started"}
|
|
||||||
|
|
||||||
@router.get("/scan/status")
|
|
||||||
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Get current scan status"""
|
|
||||||
import redis
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# Connect to Redis to get scan status
|
|
||||||
r = redis.Redis.from_url(settings.redis_url)
|
|
||||||
|
|
||||||
# Get scan status from Redis (set by worker tasks)
|
|
||||||
is_scanning = r.get('scan:active') == b'true'
|
|
||||||
current_folder = r.get('scan:current_folder')
|
|
||||||
processed_files = int(r.get('scan:processed_files') or 0)
|
|
||||||
total_files = int(r.get('scan:total_files') or 0)
|
|
||||||
errors = r.lrange('scan:errors', 0, -1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"is_scanning": is_scanning,
|
|
||||||
"current_folder": current_folder.decode() if current_folder else None,
|
|
||||||
"processed_files": processed_files,
|
|
||||||
"total_files": total_files,
|
|
||||||
"errors": [e.decode() for e in errors] if errors else []
|
|
||||||
}
|
|
||||||
@@ -1,354 +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 sqlalchemy import select, and_, or_, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Photo, Folder, Tag, PhotoTag
|
|
||||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("", response_model=PhotoListResponse)
|
|
||||||
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_picked: Optional[bool] = None,
|
|
||||||
is_rejected: Optional[bool] = None,
|
|
||||||
is_trashed: Optional[bool] = False,
|
|
||||||
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
|
|
||||||
query = select(Photo)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
if folder_id:
|
|
||||||
filters.append(Photo.folder_id == folder_id)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Flag filters
|
|
||||||
if is_picked is not None:
|
|
||||||
filters.append(Photo.is_picked == is_picked)
|
|
||||||
if is_rejected is not None:
|
|
||||||
filters.append(Photo.is_rejected == is_rejected)
|
|
||||||
|
|
||||||
# Trash filter
|
|
||||||
filters.append(Photo.is_trashed == is_trashed)
|
|
||||||
|
|
||||||
# Apply all filters
|
|
||||||
if filters:
|
|
||||||
query = query.where(and_(*filters))
|
|
||||||
|
|
||||||
# Apply sorting
|
|
||||||
sort_column = getattr(Photo, 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
|
|
||||||
return PhotoListResponse(
|
|
||||||
photos=[PhotoResponse.from_orm(photo) for photo in photos],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
per_page=per_page,
|
|
||||||
pages=(total + per_page - 1) // per_page
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/{photo_id}", response_model=PhotoResponse)
|
|
||||||
async def get_photo(
|
|
||||||
photo_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get single photo with full EXIF and 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")
|
|
||||||
|
|
||||||
return PhotoResponse.from_orm(photo)
|
|
||||||
|
|
||||||
@router.get("/{photo_id}/thumb/{size}")
|
|
||||||
async def get_thumbnail(
|
|
||||||
photo_id: str,
|
|
||||||
size: str,
|
|
||||||
response: Response,
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
|
|
||||||
if size not in ['small', 'medium', 'large']:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(Photo.id == photo_id)
|
|
||||||
)
|
|
||||||
photo = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not photo:
|
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
|
||||||
|
|
||||||
# Check if thumbnail exists, generate if not
|
|
||||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
|
||||||
thumb_path = f"{thumb_dir}/{size}.webp"
|
|
||||||
|
|
||||||
if not os.path.exists(thumb_path):
|
|
||||||
# Generate thumbnail on demand
|
|
||||||
from app.tasks.thumbs import generate_thumbnails
|
|
||||||
generate_thumbnails.delay(photo_id)
|
|
||||||
|
|
||||||
# For now, return a placeholder or the original with reduced quality
|
|
||||||
if os.path.exists(photo.filepath):
|
|
||||||
from PIL import Image
|
|
||||||
try:
|
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
|
||||||
img = Image.open(photo.filepath)
|
|
||||||
|
|
||||||
# Auto-rotate based on EXIF
|
|
||||||
from PIL import ExifTags
|
|
||||||
try:
|
|
||||||
for orientation in ExifTags.TAGS.keys():
|
|
||||||
if ExifTags.TAGS[orientation] == 'Orientation':
|
|
||||||
break
|
|
||||||
exif = img._getexif()
|
|
||||||
if exif is not None:
|
|
||||||
orient = exif.get(orientation)
|
|
||||||
if orient == 3:
|
|
||||||
img = img.rotate(180, expand=True)
|
|
||||||
elif orient == 6:
|
|
||||||
img = img.rotate(270, expand=True)
|
|
||||||
elif orient == 8:
|
|
||||||
img = img.rotate(90, expand=True)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Generate thumbnail size
|
|
||||||
sizes = {'small': 150, 'medium': 400, 'large': 800}
|
|
||||||
target_size = sizes.get(size, 400)
|
|
||||||
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Save as WebP
|
|
||||||
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating thumbnail: {e}")
|
|
||||||
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
|
|
||||||
|
|
||||||
# Check if we're behind Nginx
|
|
||||||
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
|
||||||
# Use Nginx X-Accel-Redirect for better performance
|
|
||||||
response.headers['X-Accel-Redirect'] = f'/internal_thumbs/{photo_id}/{size}.webp'
|
|
||||||
response.headers['Content-Type'] = 'image/webp'
|
|
||||||
return Response()
|
|
||||||
else:
|
|
||||||
# Direct file serving for development
|
|
||||||
return FileResponse(thumb_path, media_type='image/webp')
|
|
||||||
|
|
||||||
@router.get("/{photo_id}/original")
|
|
||||||
async def get_original(
|
|
||||||
photo_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Serve original file for download"""
|
|
||||||
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")
|
|
||||||
|
|
||||||
return FileResponse(
|
|
||||||
photo.filepath,
|
|
||||||
filename=photo.filename,
|
|
||||||
media_type='application/octet-stream'
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
|
||||||
async def update_photo(
|
|
||||||
photo_id: str,
|
|
||||||
update: PhotoUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Update photo metadata"""
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Apply updates
|
|
||||||
update_data = update.dict(exclude_unset=True)
|
|
||||||
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 trash_photo(
|
|
||||||
photo_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Move photo to trash"""
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Move file to trash directory
|
|
||||||
import shutil
|
|
||||||
trash_dir = f"{settings.trash.path}/{photo_id}"
|
|
||||||
os.makedirs(trash_dir, exist_ok=True)
|
|
||||||
|
|
||||||
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
shutil.move(photo.filepath, trash_path)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
|
|
||||||
|
|
||||||
# Update database
|
|
||||||
photo.is_trashed = True
|
|
||||||
photo.trashed_at = datetime.utcnow()
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {"status": "success", "message": "Photo moved to trash"}
|
|
||||||
|
|
||||||
@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 == 'trash':
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_trashed = True
|
|
||||||
photo.trashed_at = datetime.utcnow()
|
|
||||||
elif action.action == 'restore':
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_trashed = False
|
|
||||||
photo.trashed_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 == 'pick':
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_picked = True
|
|
||||||
photo.is_rejected = False
|
|
||||||
elif action.action == 'reject':
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_rejected = True
|
|
||||||
photo.is_picked = False
|
|
||||||
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,27 +0,0 @@
|
|||||||
"""
|
|
||||||
Tags API router
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Tag
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""List all tags with usage counts"""
|
|
||||||
result = await db.execute(select(Tag))
|
|
||||||
tags = result.scalars().all()
|
|
||||||
return tags
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Create a new tag"""
|
|
||||||
tag = Tag(name=name, color=color)
|
|
||||||
db.add(tag)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(tag)
|
|
||||||
return tag
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""
|
|
||||||
Trash API router
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select, and_
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models import Photo
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_trashed(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""List trashed photos"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(Photo.is_trashed == 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 trash"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_trashed == True))
|
|
||||||
)
|
|
||||||
photos = result.scalars().all()
|
|
||||||
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_trashed = False
|
|
||||||
photo.trashed_at = None
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "success", "restored": len(photos)}
|
|
||||||
|
|
||||||
@router.delete("/empty")
|
|
||||||
async def empty_trash(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Permanently delete all trashed photos"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Photo).where(Photo.is_trashed == True)
|
|
||||||
)
|
|
||||||
photos = result.scalars().all()
|
|
||||||
|
|
||||||
for photo in photos:
|
|
||||||
await db.delete(photo)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "success", "deleted": len(photos)}
|
|
||||||
@@ -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
|
|
||||||
is_picked: bool = False
|
|
||||||
is_rejected: bool = False
|
|
||||||
|
|
||||||
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_trashed: bool = False
|
|
||||||
trashed_at: Optional[datetime] = None
|
|
||||||
thumb_small: Optional[str] = None
|
|
||||||
thumb_medium: Optional[str] = None
|
|
||||||
thumb_large: Optional[str] = None
|
|
||||||
processing_status: str = 'pending'
|
|
||||||
processing_error: Optional[str] = None
|
|
||||||
exif_json: Optional[str] = None
|
|
||||||
is_duplicate: bool = False
|
|
||||||
live_photo_video_id: Optional[str] = None
|
|
||||||
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
orm_mode = True
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class PhotoUpdate(BaseModel):
|
|
||||||
"""Photo update schema"""
|
|
||||||
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_picked: Optional[bool] = None
|
|
||||||
is_rejected: 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 # 'trash', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick', 'reject'
|
|
||||||
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""
|
|
||||||
Metadata extraction service using ExifTool
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, Optional
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from celery import shared_task
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
|
||||||
from app.models import Photo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def parse_exif_datetime(date_str: str) -> Optional[datetime]:
|
|
||||||
"""Parse EXIF datetime string to Python datetime"""
|
|
||||||
if not date_str:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Common EXIF datetime formats
|
|
||||||
formats = [
|
|
||||||
"%Y:%m:%d %H:%M:%S",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
"%Y:%m:%d %H:%M:%S.%f",
|
|
||||||
"%Y-%m-%dT%H:%M:%S",
|
|
||||||
"%Y-%m-%dT%H:%M:%S.%f",
|
|
||||||
"%Y-%m-%dT%H:%M:%S%z"
|
|
||||||
]
|
|
||||||
|
|
||||||
for fmt in formats:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(date_str, fmt)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def extract_key_metadata(exif_data: Dict) -> Dict:
|
|
||||||
"""Extract key metadata fields for FTS indexing"""
|
|
||||||
key_fields = []
|
|
||||||
|
|
||||||
# Camera information
|
|
||||||
if 'Make' in exif_data:
|
|
||||||
key_fields.append(exif_data['Make'])
|
|
||||||
if 'Model' in exif_data:
|
|
||||||
key_fields.append(exif_data['Model'])
|
|
||||||
if 'LensModel' in exif_data:
|
|
||||||
key_fields.append(exif_data['LensModel'])
|
|
||||||
|
|
||||||
# Location information
|
|
||||||
if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data:
|
|
||||||
key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}")
|
|
||||||
|
|
||||||
# IPTC/XMP keywords
|
|
||||||
if 'Keywords' in exif_data:
|
|
||||||
if isinstance(exif_data['Keywords'], list):
|
|
||||||
key_fields.extend(exif_data['Keywords'])
|
|
||||||
else:
|
|
||||||
key_fields.append(exif_data['Keywords'])
|
|
||||||
|
|
||||||
# Copyright and creator
|
|
||||||
if 'Copyright' in exif_data:
|
|
||||||
key_fields.append(exif_data['Copyright'])
|
|
||||||
if 'Creator' in exif_data:
|
|
||||||
key_fields.append(exif_data['Creator'])
|
|
||||||
if 'Artist' in exif_data:
|
|
||||||
key_fields.append(exif_data['Artist'])
|
|
||||||
|
|
||||||
return {
|
|
||||||
'exif_text': ' '.join(key_fields),
|
|
||||||
'camera_make': exif_data.get('Make'),
|
|
||||||
'camera_model': exif_data.get('Model'),
|
|
||||||
'lens_model': exif_data.get('LensModel'),
|
|
||||||
'gps_latitude': exif_data.get('GPSLatitude'),
|
|
||||||
'gps_longitude': exif_data.get('GPSLongitude'),
|
|
||||||
}
|
|
||||||
|
|
||||||
@shared_task(name='extract_metadata')
|
|
||||||
def extract_metadata(photo_id: str):
|
|
||||||
"""Extract metadata from a photo using ExifTool"""
|
|
||||||
return asyncio.run(_extract_metadata_async(photo_id))
|
|
||||||
|
|
||||||
async def _extract_metadata_async(photo_id: str):
|
|
||||||
"""Async implementation of metadata extraction"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
# Get photo from database
|
|
||||||
result = await session.execute(
|
|
||||||
select(Photo).where(Photo.id == photo_id)
|
|
||||||
)
|
|
||||||
photo = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not photo:
|
|
||||||
logger.error(f"Photo not found: {photo_id}")
|
|
||||||
return {'status': 'error', 'message': 'Photo not found'}
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not Path(photo.filepath).exists():
|
|
||||||
logger.error(f"File not found: {photo.filepath}")
|
|
||||||
return {'status': 'error', 'message': 'File not found'}
|
|
||||||
|
|
||||||
# Run ExifTool to extract metadata
|
|
||||||
cmd = [
|
|
||||||
'exiftool',
|
|
||||||
'-j', # JSON output
|
|
||||||
'-G', # Group names
|
|
||||||
'-s', # Short output format
|
|
||||||
'-All', # All metadata
|
|
||||||
photo.filepath
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"ExifTool error: {result.stderr}")
|
|
||||||
return {'status': 'error', 'message': result.stderr}
|
|
||||||
|
|
||||||
# Parse JSON output
|
|
||||||
metadata = json.loads(result.stdout)
|
|
||||||
if metadata and len(metadata) > 0:
|
|
||||||
exif_data = metadata[0]
|
|
||||||
|
|
||||||
# Store full metadata as JSON
|
|
||||||
photo.exif_json = json.dumps(exif_data)
|
|
||||||
|
|
||||||
# Extract taken_at date
|
|
||||||
date_fields = [
|
|
||||||
'EXIF:DateTimeOriginal',
|
|
||||||
'EXIF:CreateDate',
|
|
||||||
'QuickTime:MediaCreateDate',
|
|
||||||
'EXIF:ModifyDate'
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in date_fields:
|
|
||||||
if field in exif_data:
|
|
||||||
taken_at = parse_exif_datetime(exif_data[field])
|
|
||||||
if taken_at:
|
|
||||||
photo.taken_at = taken_at
|
|
||||||
photo.taken_at_source = 'exif'
|
|
||||||
break
|
|
||||||
|
|
||||||
# Extract dimensions if not already set
|
|
||||||
if not photo.width:
|
|
||||||
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
|
|
||||||
if not photo.height:
|
|
||||||
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
|
|
||||||
|
|
||||||
# Extract and store key metadata for search
|
|
||||||
key_metadata = extract_key_metadata(exif_data)
|
|
||||||
|
|
||||||
# Update FTS table (would be done via trigger in production)
|
|
||||||
# For now, we'll store it in a comment
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"Metadata extracted for photo {photo_id}")
|
|
||||||
return {
|
|
||||||
'status': 'success',
|
|
||||||
'photo_id': photo_id,
|
|
||||||
'taken_at': photo.taken_at.isoformat() if photo.taken_at else None
|
|
||||||
}
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error(f"ExifTool timeout for {photo.filepath}")
|
|
||||||
return {'status': 'error', 'message': 'ExifTool timeout'}
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"Failed to parse ExifTool output: {e}")
|
|
||||||
return {'status': 'error', 'message': 'Invalid ExifTool output'}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error extracting metadata for {photo_id}: {e}")
|
|
||||||
return {'status': 'error', 'message': str(e)}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""
|
|
||||||
Scanner service for initial library scan
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from app.tasks.scan import scan_all_source_roots, watch_folders
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
async def start_initial_scan():
|
|
||||||
"""Start the initial library scan"""
|
|
||||||
try:
|
|
||||||
# Queue scan of all source roots
|
|
||||||
scan_all_source_roots.delay()
|
|
||||||
|
|
||||||
# Start folder watcher if configured
|
|
||||||
if settings.scanner.watch:
|
|
||||||
watch_folders.delay()
|
|
||||||
|
|
||||||
logger.info("Initial scan queued successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start initial scan: {e}")
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""
|
|
||||||
Celery tasks module
|
|
||||||
"""
|
|
||||||
from app.tasks.celery import celery_app
|
|
||||||
from app.tasks.scan import scan_folder, scan_all_source_roots, watch_folders
|
|
||||||
from app.tasks.thumbs import generate_thumbnails, regenerate_all_thumbnails
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'celery_app',
|
|
||||||
'scan_folder',
|
|
||||||
'scan_all_source_roots',
|
|
||||||
'watch_folders',
|
|
||||||
'generate_thumbnails',
|
|
||||||
'regenerate_all_thumbnails'
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""
|
|
||||||
Celery configuration and app initialization
|
|
||||||
"""
|
|
||||||
from celery import Celery
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# Create Celery app
|
|
||||||
celery_app = Celery(
|
|
||||||
'mulita',
|
|
||||||
broker=settings.celery_broker_url,
|
|
||||||
backend=settings.celery_result_backend,
|
|
||||||
include=['app.tasks.scan', 'app.tasks.thumbs']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure Celery
|
|
||||||
celery_app.conf.update(
|
|
||||||
task_serializer='json',
|
|
||||||
accept_content=['json'],
|
|
||||||
result_serializer='json',
|
|
||||||
timezone='UTC',
|
|
||||||
enable_utc=True,
|
|
||||||
task_routes={
|
|
||||||
'app.tasks.thumbs.*': {'queue': 'high'},
|
|
||||||
'app.tasks.scan.*': {'queue': 'low'},
|
|
||||||
},
|
|
||||||
task_default_queue='default',
|
|
||||||
task_default_exchange='default',
|
|
||||||
task_default_exchange_type='direct',
|
|
||||||
task_default_routing_key='default',
|
|
||||||
broker_connection_retry_on_startup=True,
|
|
||||||
)
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
"""
|
|
||||||
Celery tasks for scanning folders and indexing photos
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import hashlib
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
|
|
||||||
from celery import shared_task
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import aiofiles
|
|
||||||
|
|
||||||
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__)
|
|
||||||
|
|
||||||
# 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"""
|
|
||||||
logger.info(f"Starting scan of 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
|
|
||||||
|
|
||||||
# Walk the directory tree
|
|
||||||
total_files = 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)
|
|
||||||
|
|
||||||
# Filter supported files
|
|
||||||
supported_files = [f for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS]
|
|
||||||
total_files += len(supported_files)
|
|
||||||
|
|
||||||
# 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]
|
|
||||||
|
|
||||||
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
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get file stats
|
|
||||||
stat = os.stat(filepath)
|
|
||||||
|
|
||||||
# Calculate file hash for duplicate detection
|
|
||||||
file_hash = await calculate_file_hash(filepath)
|
|
||||||
|
|
||||||
# Check for duplicate by hash
|
|
||||||
duplicate = await session.execute(
|
|
||||||
select(Photo).where(Photo.file_hash == file_hash)
|
|
||||||
) if file_hash else None
|
|
||||||
|
|
||||||
# Create photo entry
|
|
||||||
photo = Photo(
|
|
||||||
filepath=filepath,
|
|
||||||
filename=filename,
|
|
||||||
folder_id=folder.id,
|
|
||||||
file_hash=file_hash,
|
|
||||||
media_type=get_media_type(filepath),
|
|
||||||
original_format=Path(filepath).suffix.upper()[1:],
|
|
||||||
file_size=stat.st_size,
|
|
||||||
taken_at=datetime.fromtimestamp(stat.st_mtime),
|
|
||||||
taken_at_source='filesystem',
|
|
||||||
is_duplicate=bool(duplicate.scalar_one_or_none() if duplicate else False),
|
|
||||||
processing_status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(photo)
|
|
||||||
await session.flush() # Get the photo ID
|
|
||||||
|
|
||||||
# Queue thumbnail generation
|
|
||||||
generate_thumbnails.delay(photo.id)
|
|
||||||
|
|
||||||
# Queue metadata extraction
|
|
||||||
extract_metadata.delay(photo.id)
|
|
||||||
|
|
||||||
processed_files += 1
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
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)})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Commit batch
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# 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}")
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
|
|
||||||
"""Get or create a source root entry"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SourceRoot).where(SourceRoot.path == path)
|
|
||||||
)
|
|
||||||
source_root = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not source_root:
|
|
||||||
source_root = SourceRoot(
|
|
||||||
name=Path(path).name,
|
|
||||||
path=path
|
|
||||||
)
|
|
||||||
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"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(Folder).where(Folder.path == path)
|
|
||||||
)
|
|
||||||
folder = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not folder:
|
|
||||||
parent_path = str(Path(path).parent)
|
|
||||||
parent = None
|
|
||||||
|
|
||||||
if parent_path != path: # Not root folder
|
|
||||||
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(path).name,
|
|
||||||
path=path,
|
|
||||||
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 all configured source roots"""
|
|
||||||
for source_root in settings.source_roots:
|
|
||||||
if os.path.exists(source_root.path):
|
|
||||||
scan_folder.delay(source_root.path)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Source root path does not exist: {source_root.path}")
|
|
||||||
|
|
||||||
@shared_task(name='watch_folders')
|
|
||||||
def watch_folders():
|
|
||||||
"""
|
|
||||||
Watch folders for changes using watchfiles
|
|
||||||
This is a long-running task that monitors file system events
|
|
||||||
"""
|
|
||||||
from watchfiles import watch
|
|
||||||
|
|
||||||
paths = [sr.path for sr in settings.source_roots if os.path.exists(sr.path)]
|
|
||||||
|
|
||||||
if not paths:
|
|
||||||
logger.warning("No valid source roots to watch")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Starting folder watcher for: {paths}")
|
|
||||||
|
|
||||||
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
|
|
||||||
parent_dir = str(Path(filepath).parent)
|
|
||||||
scan_folder.delay(parent_dir)
|
|
||||||
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_trashed = True
|
|
||||||
photo.trashed_at = datetime.utcnow()
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"Marked photo as trashed: {filepath}")
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
"""
|
|
||||||
Celery tasks for thumbnail generation
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
import logging
|
|
||||||
from typing import Tuple, Optional
|
|
||||||
import json
|
|
||||||
|
|
||||||
from celery import shared_task
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from PIL import Image
|
|
||||||
import imageio
|
|
||||||
from pillow_heif import register_heif_opener
|
|
||||||
import ffmpeg
|
|
||||||
|
|
||||||
# Try to import optional libraries
|
|
||||||
try:
|
|
||||||
import pyvips
|
|
||||||
PYVIPS_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
PYVIPS_AVAILABLE = False
|
|
||||||
print("pyvips not available, using Pillow for image processing")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import rawpy
|
|
||||||
RAWPY_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
RAWPY_AVAILABLE = False
|
|
||||||
print("rawpy not available, using exiftool for RAW preview extraction")
|
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
|
||||||
from app.models import Photo
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# Register HEIF opener with Pillow
|
|
||||||
register_heif_opener()
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Thumbnail sizes configuration
|
|
||||||
THUMB_SIZES = {
|
|
||||||
'small': settings.thumbnails.small,
|
|
||||||
'medium': settings.thumbnails.medium,
|
|
||||||
'large': settings.thumbnails.large
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_thumb_path(photo_id: str, size: str) -> str:
|
|
||||||
"""Get the path for a thumbnail file"""
|
|
||||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
|
||||||
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
|
|
||||||
|
|
||||||
def process_standard_image(filepath: str) -> Image.Image:
|
|
||||||
"""Process standard image formats (JPEG, PNG, etc.)"""
|
|
||||||
return Image.open(filepath)
|
|
||||||
|
|
||||||
def process_raw_image(filepath: str) -> Image.Image:
|
|
||||||
"""Process RAW image formats"""
|
|
||||||
if RAWPY_AVAILABLE:
|
|
||||||
try:
|
|
||||||
with rawpy.imread(filepath) as raw:
|
|
||||||
# Use half_size for faster processing
|
|
||||||
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
|
|
||||||
# Convert numpy array to PIL Image
|
|
||||||
return Image.fromarray(rgb, 'RGB')
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing RAW file {filepath}: {e}")
|
|
||||||
# Try to extract embedded JPEG preview
|
|
||||||
return extract_raw_preview(filepath)
|
|
||||||
else:
|
|
||||||
# Use exiftool to extract embedded preview
|
|
||||||
return extract_raw_preview(filepath)
|
|
||||||
|
|
||||||
def extract_raw_preview(filepath: str) -> Optional[Image.Image]:
|
|
||||||
"""Extract embedded JPEG preview from RAW file"""
|
|
||||||
try:
|
|
||||||
# Use exiftool to extract preview
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
|
||||||
cmd = ['exiftool', '-b', '-PreviewImage', filepath]
|
|
||||||
result = subprocess.run(cmd, capture_output=True)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout:
|
|
||||||
tmp.write(result.stdout)
|
|
||||||
tmp.flush()
|
|
||||||
return Image.open(tmp.name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def process_heic_image(filepath: str) -> Image.Image:
|
|
||||||
"""Process HEIC/HEIF image formats"""
|
|
||||||
try:
|
|
||||||
# Use pillow-heif to open the image
|
|
||||||
img = Image.open(filepath)
|
|
||||||
# Convert to RGB if needed
|
|
||||||
if img.mode != 'RGB':
|
|
||||||
img = img.convert('RGB')
|
|
||||||
return img
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing HEIC file {filepath}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def process_video_thumbnail(filepath: str) -> Image.Image:
|
|
||||||
"""Extract thumbnail from video file"""
|
|
||||||
try:
|
|
||||||
# Get video duration
|
|
||||||
probe = ffmpeg.probe(filepath)
|
|
||||||
duration = float(probe['streams'][0]['duration'])
|
|
||||||
|
|
||||||
# Extract frame at 10% of duration
|
|
||||||
timestamp = duration * 0.1
|
|
||||||
|
|
||||||
# Extract frame using ffmpeg
|
|
||||||
import tempfile
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
|
||||||
stream = ffmpeg.input(filepath, ss=timestamp)
|
|
||||||
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
|
|
||||||
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
|
|
||||||
|
|
||||||
return Image.open(tmp.name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
|
|
||||||
# Create a placeholder thumbnail
|
|
||||||
return create_placeholder_thumbnail('video')
|
|
||||||
|
|
||||||
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
|
|
||||||
"""Create a placeholder thumbnail for failed processing"""
|
|
||||||
# Create a simple gray placeholder
|
|
||||||
img = Image.new('RGB', (640, 480), color=(128, 128, 128))
|
|
||||||
return img
|
|
||||||
|
|
||||||
def auto_rotate_image(image: Image.Image) -> Image.Image:
|
|
||||||
"""Auto-rotate image based on EXIF orientation"""
|
|
||||||
try:
|
|
||||||
# Get EXIF data
|
|
||||||
exif = image._getexif()
|
|
||||||
if exif:
|
|
||||||
orientation = exif.get(274) # Orientation tag
|
|
||||||
|
|
||||||
rotation_map = {
|
|
||||||
3: 180,
|
|
||||||
6: 270, # Note: PIL uses different rotation values than vips
|
|
||||||
8: 90
|
|
||||||
}
|
|
||||||
|
|
||||||
if orientation in rotation_map:
|
|
||||||
image = image.rotate(rotation_map[orientation], expand=True)
|
|
||||||
except:
|
|
||||||
pass # No orientation data available
|
|
||||||
|
|
||||||
return image
|
|
||||||
|
|
||||||
def generate_thumbnail(image: Image.Image, size: int, output_path: str):
|
|
||||||
"""Generate a thumbnail of the specified size"""
|
|
||||||
# Maintain aspect ratio
|
|
||||||
image.thumbnail((size, size), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Save as WebP with specified quality
|
|
||||||
image.save(
|
|
||||||
output_path,
|
|
||||||
'WEBP',
|
|
||||||
quality=settings.thumbnails.quality,
|
|
||||||
method=4 # Balance between speed and compression
|
|
||||||
)
|
|
||||||
|
|
||||||
@shared_task(bind=True, name='generate_thumbnails')
|
|
||||||
def generate_thumbnails(self, photo_id: str):
|
|
||||||
"""Generate thumbnails for a photo"""
|
|
||||||
return asyncio.run(_generate_thumbnails_async(photo_id, self))
|
|
||||||
|
|
||||||
async def _generate_thumbnails_async(photo_id: str, task):
|
|
||||||
"""Async implementation of thumbnail generation"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
# Get photo from database
|
|
||||||
result = await session.execute(
|
|
||||||
select(Photo).where(Photo.id == photo_id)
|
|
||||||
)
|
|
||||||
photo = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not photo:
|
|
||||||
logger.error(f"Photo not found: {photo_id}")
|
|
||||||
return {'status': 'error', 'message': 'Photo not found'}
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not os.path.exists(photo.filepath):
|
|
||||||
logger.error(f"File not found: {photo.filepath}")
|
|
||||||
photo.processing_status = 'failed'
|
|
||||||
photo.processing_error = 'File not found'
|
|
||||||
await session.commit()
|
|
||||||
return {'status': 'error', 'message': 'File not found'}
|
|
||||||
|
|
||||||
# Update processing status
|
|
||||||
photo.processing_status = 'processing'
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Load and process the image based on type
|
|
||||||
image = None
|
|
||||||
|
|
||||||
if photo.media_type == 'photo':
|
|
||||||
image = process_standard_image(photo.filepath)
|
|
||||||
elif photo.media_type == 'raw':
|
|
||||||
image = process_raw_image(photo.filepath)
|
|
||||||
elif photo.media_type == 'heic':
|
|
||||||
image = process_heic_image(photo.filepath)
|
|
||||||
elif photo.media_type == 'video':
|
|
||||||
image = process_video_thumbnail(photo.filepath)
|
|
||||||
else:
|
|
||||||
logger.error(f"Unsupported media type: {photo.media_type}")
|
|
||||||
image = create_placeholder_thumbnail(photo.media_type)
|
|
||||||
|
|
||||||
if not image:
|
|
||||||
raise Exception("Failed to process image")
|
|
||||||
|
|
||||||
# Auto-rotate based on EXIF
|
|
||||||
image = auto_rotate_image(image)
|
|
||||||
|
|
||||||
# Store original dimensions
|
|
||||||
photo.width = image.width
|
|
||||||
photo.height = image.height
|
|
||||||
|
|
||||||
# Generate thumbnails for each size
|
|
||||||
for size_name, size_value in THUMB_SIZES.items():
|
|
||||||
thumb_path = get_thumb_path(photo_id, size_name)
|
|
||||||
generate_thumbnail(image, size_value, thumb_path)
|
|
||||||
|
|
||||||
# Update database with thumbnail path
|
|
||||||
setattr(photo, f'thumb_{size_name}', thumb_path)
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
task.update_state(
|
|
||||||
state='PROGRESS',
|
|
||||||
meta={'current_size': size_name, 'photo_id': photo_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update processing status
|
|
||||||
photo.processing_status = 'completed'
|
|
||||||
photo.processing_error = None
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"Thumbnails generated for photo {photo_id}")
|
|
||||||
return {'status': 'success', 'photo_id': photo_id}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating thumbnails for {photo_id}: {e}")
|
|
||||||
|
|
||||||
# Update error status
|
|
||||||
if photo:
|
|
||||||
photo.processing_status = 'failed'
|
|
||||||
photo.processing_error = str(e)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {'status': 'error', 'message': str(e)}
|
|
||||||
|
|
||||||
@shared_task(name='regenerate_all_thumbnails')
|
|
||||||
def regenerate_all_thumbnails():
|
|
||||||
"""Regenerate thumbnails for all photos"""
|
|
||||||
return asyncio.run(_regenerate_all_thumbnails_async())
|
|
||||||
|
|
||||||
async def _regenerate_all_thumbnails_async():
|
|
||||||
"""Async implementation of regenerating all thumbnails"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
# Get all photos that need thumbnails
|
|
||||||
result = await session.execute(
|
|
||||||
select(Photo).where(
|
|
||||||
Photo.processing_status.in_(['pending', 'failed'])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
photos = result.scalars().all()
|
|
||||||
|
|
||||||
logger.info(f"Regenerating thumbnails for {len(photos)} photos")
|
|
||||||
|
|
||||||
for photo in photos:
|
|
||||||
generate_thumbnails.delay(photo.id)
|
|
||||||
|
|
||||||
return {'status': 'queued', 'count': len(photos)}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Core dependencies
|
|
||||||
fastapi==0.109.0
|
|
||||||
uvicorn[standard]==0.27.0
|
|
||||||
python-multipart==0.0.6
|
|
||||||
|
|
||||||
# Database
|
|
||||||
sqlalchemy[asyncio]==2.0.25
|
|
||||||
aiosqlite==0.19.0
|
|
||||||
alembic==1.13.1
|
|
||||||
|
|
||||||
# Redis and Celery
|
|
||||||
redis==5.0.1
|
|
||||||
celery==5.3.6
|
|
||||||
flower==2.0.1
|
|
||||||
|
|
||||||
# Image processing
|
|
||||||
# pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback
|
|
||||||
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
|
|
||||||
pillow==10.2.0
|
|
||||||
pillow-heif==0.15.0
|
|
||||||
imageio==2.33.1
|
|
||||||
imageio-ffmpeg==0.4.9
|
|
||||||
|
|
||||||
# Video processing
|
|
||||||
ffmpeg-python==0.2.0
|
|
||||||
|
|
||||||
# Metadata extraction
|
|
||||||
pyexiftool==0.5.6
|
|
||||||
|
|
||||||
# File watching
|
|
||||||
watchfiles==0.21.0
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
pyyaml==6.0.1
|
|
||||||
pydantic==2.5.3
|
|
||||||
pydantic-settings==2.1.0
|
|
||||||
python-dotenv==1.0.0
|
|
||||||
httpx==0.26.0
|
|
||||||
aiofiles==23.2.1
|
|
||||||
|
|
||||||
# Security and authentication
|
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
passlib[bcrypt]==1.7.4
|
|
||||||
|
|
||||||
# Development
|
|
||||||
pytest==7.4.4
|
|
||||||
pytest-asyncio==0.23.3
|
|
||||||
black==23.12.1
|
|
||||||
ruff==0.1.11
|
|
||||||
24
docker-compose.gpu.yml
Normal file
24
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
|
||||||
|
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
|
||||||
|
# ships VA-API-enabled ffmpeg; this file just wires the device + group
|
||||||
|
# membership + encoder selection. Layered in by the deploy script on
|
||||||
|
# hosts where /dev/dri/renderD128 exists.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
photoprism:
|
||||||
|
devices:
|
||||||
|
- /dev/dri/renderD128:/dev/dri/renderD128
|
||||||
|
- /dev/dri/card0:/dev/dri/card0
|
||||||
|
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
|
||||||
|
# container user (PP_UID:PP_GID, typically 33:10000) is not in
|
||||||
|
# these groups by default; group_add grants access to the device
|
||||||
|
# nodes without changing the primary user.
|
||||||
|
group_add:
|
||||||
|
- "992"
|
||||||
|
- "44"
|
||||||
|
environment:
|
||||||
|
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
|
||||||
|
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}
|
||||||
34
docker-compose.podman.yml
Normal file
34
docker-compose.podman.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Podman-rootless overlay for the PhotoPrism stack.
|
||||||
|
#
|
||||||
|
# Apply alongside the base compose file:
|
||||||
|
# podman-compose --env-file .env \
|
||||||
|
# -f docker-compose.yml \
|
||||||
|
# -f docker-compose.podman.yml \
|
||||||
|
# up -d
|
||||||
|
#
|
||||||
|
# Adds the podman-specific bits that would break a vanilla docker compose run:
|
||||||
|
# - userns_mode: keep-id maps container UID to the invoking host UID, so
|
||||||
|
# PhotoPrism (running as PP_UID:PP_GID inside) can actually read the
|
||||||
|
# bind-mounted originals volume on the host (which is owned by the host
|
||||||
|
# user, not by uid 1000-in-the-container-namespace).
|
||||||
|
# - the explicit security_opt entries on the base file work in podman as-is.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# MariaDB writes to a named volume managed by podman; its in-container
|
||||||
|
# `mysql` user expects to own that volume. keep-id breaks this by mapping
|
||||||
|
# in-container UID 999 to a podman-subuid that doesn't own the volume,
|
||||||
|
# so let mariadb use the default userns mapping (root-in-namespace).
|
||||||
|
mariadb:
|
||||||
|
# No userns_mode override — use podman defaults.
|
||||||
|
init: true
|
||||||
|
|
||||||
|
# PhotoPrism does need keep-id, so its container UID maps back to the
|
||||||
|
# host UID that owns the bind-mounted originals/.
|
||||||
|
photoprism:
|
||||||
|
userns_mode: keep-id
|
||||||
|
|
||||||
|
# Sidecar mutates the originals tree (rename / folder ops / heap
|
||||||
|
# convert / .duplicates archive) — same keep-id mapping so its writes
|
||||||
|
# land as the host user, not as a podman-subuid the host doesn't own.
|
||||||
|
sidecar:
|
||||||
|
userns_mode: keep-id
|
||||||
@@ -1,90 +1,209 @@
|
|||||||
version: '3.8'
|
# Compose stack for the PhotoPrism-backed photo app: mariadb + photoprism +
|
||||||
|
# Go sidecar. The SvelteKit web/ frontend runs separately (Vite in dev,
|
||||||
|
# static build in prod) and proxies /api/v1/* to photoprism and
|
||||||
|
# /api/sidecar/* to the sidecar.
|
||||||
|
#
|
||||||
|
# podman-compose --env-file .env \
|
||||||
|
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
frontend:
|
mariadb:
|
||||||
build:
|
# Fully-qualified for podman (which refuses short names by default).
|
||||||
context: ./frontend
|
# Docker resolves the same digest.
|
||||||
dockerfile: Dockerfile
|
image: docker.io/library/mariadb:11
|
||||||
container_name: mulita-frontend
|
container_name: pp-mariadb
|
||||||
ports:
|
|
||||||
- "3000:80"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
networks:
|
|
||||||
- mulita-network
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
backend:
|
- --innodb-buffer-pool-size=512M
|
||||||
build:
|
- --transaction-isolation=READ-COMMITTED
|
||||||
context: ./backend
|
- --character-set-server=utf8mb4
|
||||||
dockerfile: Dockerfile
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
container_name: mulita-backend
|
- --max-connections=512
|
||||||
ports:
|
- --innodb-rollback-on-timeout=OFF
|
||||||
- "8001:8000"
|
- --innodb-lock-wait-timeout=120
|
||||||
volumes:
|
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
|
||||||
- ~/Pictures:/host/Pictures:ro
|
|
||||||
- thumbs_data:/data/thumbs
|
|
||||||
- db_data:/data/db
|
|
||||||
- trash_data:/data/trash
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
MARIADB_AUTO_UPGRADE: "1"
|
||||||
- REDIS_URL=redis://redis:6379
|
MARIADB_INITDB_SKIP_TZINFO: "1"
|
||||||
- CELERY_BROKER_URL=redis://redis:6379
|
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env}
|
||||||
depends_on:
|
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env}
|
||||||
- redis
|
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||||
networks:
|
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||||
- mulita-network
|
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||||
restart: unless-stopped
|
# name on the photoprism-network bridge.
|
||||||
|
|
||||||
worker:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: mulita-worker
|
|
||||||
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4
|
|
||||||
volumes:
|
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
|
||||||
- ~/Pictures:/host/Pictures:ro
|
|
||||||
- thumbs_data:/data/thumbs
|
|
||||||
- db_data:/data/db
|
|
||||||
- trash_data:/data/trash
|
|
||||||
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=4
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
- backend
|
|
||||||
networks:
|
|
||||||
- mulita-network
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: mulita-redis
|
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- pp_mariadb_data:/var/lib/mysql
|
||||||
networks:
|
# The init script creates the mule_sidecar database + user that the Go
|
||||||
- mulita-network
|
# 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", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 60s
|
||||||
|
networks: [photoprism-network]
|
||||||
|
|
||||||
|
photoprism:
|
||||||
|
image: docker.io/photoprism/photoprism:latest
|
||||||
|
container_name: pp-app
|
||||||
restart: unless-stopped
|
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:
|
networks:
|
||||||
mulita-network:
|
photoprism-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
thumbs_data:
|
pp_mariadb_data:
|
||||||
db_data:
|
|
||||||
trash_data:
|
|
||||||
redis_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,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Mulita - Photo Management</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Enable gzip
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
|
||||||
|
|
||||||
# API proxy
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket support for real-time updates
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Thumbnail serving with X-Accel-Redirect
|
|
||||||
location /internal_thumbs/ {
|
|
||||||
internal;
|
|
||||||
alias /data/thumbs/;
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA routing - serve index.html for all routes
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6069
frontend/package-lock.json
generated
6069
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mulita-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
|
||||||
"@radix-ui/react-context-menu": "^2.1.5",
|
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
|
||||||
"@tanstack/react-query": "^5.17.0",
|
|
||||||
"@tanstack/react-virtual": "^3.0.1",
|
|
||||||
"axios": "^1.6.5",
|
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"date-fns": "^3.2.0",
|
|
||||||
"framer-motion": "^10.18.0",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide-react": "^0.303.0",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-hotkeys-hook": "^4.4.3",
|
|
||||||
"react-intersection-observer": "^9.5.3",
|
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"tailwind-merge": "^2.2.0",
|
|
||||||
"zustand": "^4.4.7"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tanstack/react-query-devtools": "^5.96.2",
|
|
||||||
"@types/leaflet": "^1.9.8",
|
|
||||||
"@types/react": "^18.2.46",
|
|
||||||
"@types/react-dom": "^18.2.18",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"eslint": "^8.56.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
|
||||||
"postcss": "^8.4.33",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"vite": "^5.0.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Timeline } from './components/timeline/Timeline'
|
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
|
||||||
import { TopBar } from './components/layout/TopBar'
|
|
||||||
import { ScanProgress } from './components/ScanProgress'
|
|
||||||
import { ToastContainer } from './components/ToastContainer'
|
|
||||||
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
|
|
||||||
import { KeyboardHints } from './components/KeyboardHints'
|
|
||||||
import { usePhotoStore } from './store/photoStore'
|
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
|
||||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
|
||||||
|
|
||||||
// Set up global keyboard shortcuts
|
|
||||||
useKeyboardShortcuts({
|
|
||||||
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
|
||||||
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show right sidebar when photos are selected
|
|
||||||
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
|
||||||
setRightSidebarOpen(true)
|
|
||||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
|
||||||
setRightSidebarOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
|
||||||
<TopBar />
|
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* Left Sidebar */}
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-200 ${
|
|
||||||
leftSidebarOpen ? 'w-64' : 'w-0'
|
|
||||||
} overflow-hidden border-r border-border bg-surface`}
|
|
||||||
>
|
|
||||||
<LeftSidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content - Timeline */}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<Timeline />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Sidebar */}
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-200 ${
|
|
||||||
rightSidebarOpen ? 'w-80' : 'w-0'
|
|
||||||
} overflow-hidden border-l border-border bg-surface`}
|
|
||||||
>
|
|
||||||
<RightSidebar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contextual Keyboard Hints */}
|
|
||||||
<KeyboardHints />
|
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Legend */}
|
|
||||||
<KeyboardShortcuts />
|
|
||||||
|
|
||||||
{/* Scan Progress Indicator */}
|
|
||||||
<ScanProgress />
|
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
|
||||||
<ToastContainer />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { usePhotoStore } from '../store/photoStore'
|
|
||||||
|
|
||||||
export function KeyboardHints() {
|
|
||||||
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
|
||||||
|
|
||||||
const hints = selectedCount > 0 ? [
|
|
||||||
{ key: '1-5', action: 'Rate' },
|
|
||||||
{ key: 'P', action: 'Pick' },
|
|
||||||
{ key: 'X', action: 'Reject' },
|
|
||||||
{ key: 'Delete', action: 'Trash' },
|
|
||||||
{ key: 'Esc', action: 'Deselect' },
|
|
||||||
] : [
|
|
||||||
{ key: '↑↓←→', action: 'Navigate' },
|
|
||||||
{ key: 'Click', action: 'Select' },
|
|
||||||
{ key: 'Shift+Click', action: 'Range' },
|
|
||||||
{ key: 'Ctrl+A', action: 'Select All' },
|
|
||||||
{ key: 'Space', action: 'Preview' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed top-14 left-1/2 z-20 -translate-x-1/2">
|
|
||||||
<div className="flex items-center gap-3 rounded-full border border-border bg-surface/90 px-4 py-2 shadow-lg backdrop-blur-sm">
|
|
||||||
{hints.map((hint, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
|
||||||
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-xs font-medium text-text">
|
|
||||||
{hint.key}
|
|
||||||
</kbd>
|
|
||||||
<span className="text-xs text-text-muted">{hint.action}</span>
|
|
||||||
{i < hints.length - 1 && (
|
|
||||||
<span className="ml-2 text-text-faint">•</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="ml-2 text-text-faint">•</span>
|
|
||||||
<span className="text-xs font-medium text-primary">
|
|
||||||
{selectedCount} selected
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Keyboard, ChevronRight, ChevronDown, X } from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface Shortcut {
|
|
||||||
keys: string[]
|
|
||||||
description: string
|
|
||||||
category: 'navigation' | 'selection' | 'actions' | 'view'
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcuts: Shortcut[] = [
|
|
||||||
// Navigation
|
|
||||||
{ keys: ['↑', '↓', '←', '→'], description: 'Navigate photos', category: 'navigation' },
|
|
||||||
{ keys: ['Space'], description: 'Quick preview', category: 'navigation' },
|
|
||||||
{ keys: ['Enter'], description: 'Open in loupe view', category: 'navigation' },
|
|
||||||
|
|
||||||
// Selection
|
|
||||||
{ keys: ['Click'], description: 'Select photo', category: 'selection' },
|
|
||||||
{ keys: ['Shift', 'Click'], description: 'Select range', category: 'selection' },
|
|
||||||
{ keys: ['Ctrl/Cmd', 'Click'], description: 'Add to selection', category: 'selection' },
|
|
||||||
{ keys: ['Ctrl/Cmd', 'A'], description: 'Select all', category: 'selection' },
|
|
||||||
{ keys: ['Escape'], description: 'Clear selection', category: 'selection' },
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
{ keys: ['1-5'], description: 'Set rating', category: 'actions' },
|
|
||||||
{ keys: ['0'], description: 'Remove rating', category: 'actions' },
|
|
||||||
{ keys: ['P'], description: 'Pick photo', category: 'actions' },
|
|
||||||
{ keys: ['X'], description: 'Reject photo', category: 'actions' },
|
|
||||||
{ keys: ['U'], description: 'Unflag photo', category: 'actions' },
|
|
||||||
{ keys: ['Delete'], description: 'Move to trash', category: 'actions' },
|
|
||||||
|
|
||||||
// View
|
|
||||||
{ keys: ['Tab'], description: 'Toggle left sidebar', category: 'view' },
|
|
||||||
{ keys: ['I'], description: 'Toggle info panel', category: 'view' },
|
|
||||||
{ keys: ['G'], description: 'Grid view', category: 'view' },
|
|
||||||
{ keys: ['E'], description: 'Loupe view', category: 'view' },
|
|
||||||
{ keys: ['F'], description: 'Fullscreen', category: 'view' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function KeyboardShortcuts() {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
|
||||||
|
|
||||||
const categories = {
|
|
||||||
navigation: { label: 'Navigation', color: 'text-primary' },
|
|
||||||
selection: { label: 'Selection', color: 'text-pick' },
|
|
||||||
actions: { label: 'Actions', color: 'text-star' },
|
|
||||||
view: { label: 'View', color: 'text-text' },
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMinimized) {
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-4 z-30">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMinimized(false)}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface/90 px-3 py-2 text-sm backdrop-blur-sm hover:bg-surface"
|
|
||||||
title="Show keyboard shortcuts"
|
|
||||||
>
|
|
||||||
<Keyboard className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-text-muted">Shortcuts</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-4 z-30 w-80 overflow-hidden rounded-lg border border-border bg-surface/95 shadow-xl backdrop-blur-sm">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between bg-surface-2 px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Keyboard className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-sm font-medium text-text">Keyboard Shortcuts</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
||||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMinimized(true)}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
||||||
title="Minimize"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="max-h-96 overflow-y-auto p-2">
|
|
||||||
{Object.entries(categories).map(([category, { label, color }]) => (
|
|
||||||
<div key={category} className="mb-3">
|
|
||||||
<h3 className={clsx('mb-1.5 text-xs font-semibold uppercase', color)}>
|
|
||||||
{label}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{shortcuts
|
|
||||||
.filter(s => s.category === category)
|
|
||||||
.map((shortcut, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center justify-between rounded px-2 py-1 hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
{shortcut.description}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{shortcut.keys.map((key, j) => (
|
|
||||||
<span key={j} className="flex items-center">
|
|
||||||
<kbd className="rounded bg-surface-offset px-1.5 py-0.5 text-[10px] font-medium text-text">
|
|
||||||
{key}
|
|
||||||
</kbd>
|
|
||||||
{j < shortcut.keys.length - 1 && (
|
|
||||||
<span className="mx-0.5 text-[10px] text-text-muted">+</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer Hint */}
|
|
||||||
{!isExpanded && (
|
|
||||||
<div className="px-3 pb-2 pt-1">
|
|
||||||
<p className="text-xs text-text-muted">Click to expand shortcuts list</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
|
|
||||||
import { useQuery } 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)
|
|
||||||
|
|
||||||
// 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(() => {
|
|
||||||
if (scanStatus?.is_scanning) {
|
|
||||||
setIsVisible(true)
|
|
||||||
setIsMinimized(false)
|
|
||||||
} else if (isVisible && !scanStatus?.is_scanning && (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])
|
|
||||||
|
|
||||||
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,95 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id: string
|
|
||||||
type: 'success' | 'error' | 'info' | 'warning'
|
|
||||||
title: string
|
|
||||||
message?: string
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) => addToast('success', title, message),
|
|
||||||
error: (title: string, message?: string) => addToast('error', title, message),
|
|
||||||
info: (title: string, message?: string) => addToast('info', title, message),
|
|
||||||
warning: (title: string, message?: string) => addToast('warning', title, message),
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToast(type: Toast['type'], title: string, message?: string, duration = 5000) {
|
|
||||||
const id = Date.now().toString()
|
|
||||||
const newToast: Toast = { id, type, title, message, duration }
|
|
||||||
toastList = [...toastList, newToast]
|
|
||||||
toastListeners.forEach(listener => listener(toastList))
|
|
||||||
|
|
||||||
// Auto-remove after duration
|
|
||||||
setTimeout(() => {
|
|
||||||
removeToast(id)
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeToast(id: string) {
|
|
||||||
toastList = toastList.filter(t => t.id !== id)
|
|
||||||
toastListeners.forEach(listener => listener(toastList))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastContainer() {
|
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listener = (newToasts: Toast[]) => setToasts(newToasts)
|
|
||||||
toastListeners.push(listener)
|
|
||||||
return () => {
|
|
||||||
toastListeners = toastListeners.filter(l => l !== listener)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
success: <CheckCircle className="h-5 w-5 text-pick" />,
|
|
||||||
error: <XCircle className="h-5 w-5 text-reject" />,
|
|
||||||
info: <Info className="h-5 w-5 text-primary" />,
|
|
||||||
warning: <AlertCircle className="h-5 w-5 text-star" />,
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
success: 'border-pick bg-pick/10',
|
|
||||||
error: 'border-reject bg-reject/10',
|
|
||||||
info: 'border-primary bg-primary/10',
|
|
||||||
warning: 'border-star bg-star/10',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2">
|
|
||||||
{toasts.map((toast) => (
|
|
||||||
<div
|
|
||||||
key={toast.id}
|
|
||||||
className={clsx(
|
|
||||||
'pointer-events-auto flex items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-sm transition-all duration-300',
|
|
||||||
'animate-slide-up',
|
|
||||||
colors[toast.type]
|
|
||||||
)}
|
|
||||||
style={{ minWidth: '300px', maxWidth: '400px' }}
|
|
||||||
>
|
|
||||||
{icons[toast.type]}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-text">{toast.title}</div>
|
|
||||||
{toast.message && (
|
|
||||||
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => removeToast(toast.id)}
|
|
||||||
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { X, FolderPlus, AlertCircle } from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface AddSourceFolderDialogProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onAdd: (path: string, recursive: boolean) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) {
|
|
||||||
const [folderPath, setFolderPath] = useState('')
|
|
||||||
const [recursive, setRecursive] = useState(true)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!folderPath.trim()) {
|
|
||||||
setError('Please enter a folder path')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onAdd(folderPath.trim(), recursive)
|
|
||||||
setFolderPath('')
|
|
||||||
setRecursive(true)
|
|
||||||
onClose()
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Failed to add source folder')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isLoading) {
|
|
||||||
setFolderPath('')
|
|
||||||
setError(null)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dialog */}
|
|
||||||
<div className="relative z-10 w-full max-w-md rounded-lg bg-surface border border-border p-6 shadow-xl">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FolderPlus className="h-5 w-5 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold text-text">Add Source Folder</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{/* Path Input */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="folderPath" className="mb-1 block text-sm text-text-muted">
|
|
||||||
Folder Path
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="folderPath"
|
|
||||||
type="text"
|
|
||||||
value={folderPath}
|
|
||||||
onChange={(e) => setFolderPath(e.target.value)}
|
|
||||||
placeholder="/host/Pictures/your-folder"
|
|
||||||
disabled={isLoading}
|
|
||||||
className={clsx(
|
|
||||||
'w-full rounded border bg-bg px-3 py-2 text-sm text-text placeholder-text-faint',
|
|
||||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
|
||||||
'disabled:opacity-50',
|
|
||||||
error ? 'border-reject' : 'border-border'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-1 space-y-1">
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
Use container paths. Your Pictures folder is available at:
|
|
||||||
</p>
|
|
||||||
<code className="block text-xs bg-surface-2 px-2 py-1 rounded text-primary">
|
|
||||||
/host/Pictures/
|
|
||||||
</code>
|
|
||||||
<p className="text-xs text-text-faint">
|
|
||||||
Example: /host/Pictures/MulitaTest
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recursive Checkbox */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="recursive"
|
|
||||||
type="checkbox"
|
|
||||||
checked={recursive}
|
|
||||||
onChange={(e) => setRecursive(e.target.checked)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<label htmlFor="recursive" className="text-sm text-text">
|
|
||||||
Include subfolders
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="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>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || !folderPath.trim()}
|
|
||||||
className="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Adding...' : 'Add Folder'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
Folder,
|
|
||||||
Image,
|
|
||||||
Calendar,
|
|
||||||
Star,
|
|
||||||
Flag,
|
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
MoreHorizontal,
|
|
||||||
HardDrive,
|
|
||||||
RefreshCw
|
|
||||||
} from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
|
||||||
import { sourceFolders, library } from '../../services/api'
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from '../ToastContainer'
|
|
||||||
|
|
||||||
interface TreeItem {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon?: React.ReactNode
|
|
||||||
count?: number
|
|
||||||
children?: TreeItem[]
|
|
||||||
type?: 'folder' | 'heap' | 'special'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeftSidebar() {
|
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
|
||||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
|
||||||
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// Fetch folders from API
|
|
||||||
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
|
||||||
queryKey: ['folders'],
|
|
||||||
queryFn: sourceFolders.list,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mutation for adding folders
|
|
||||||
const addFolderMutation = useMutation({
|
|
||||||
mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => {
|
|
||||||
// Add the folder
|
|
||||||
const folder = await sourceFolders.add(path, recursive)
|
|
||||||
// Trigger scan for the new folder
|
|
||||||
await sourceFolders.scan(folder.id)
|
|
||||||
return folder
|
|
||||||
},
|
|
||||||
onSuccess: (folder) => {
|
|
||||||
toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`)
|
|
||||||
// Refetch folders list
|
|
||||||
refetchFolders()
|
|
||||||
// Refetch photos to show new ones
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error('Failed to Add Folder', error.message || 'An error occurred')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mutation for scanning all folders
|
|
||||||
const scanLibraryMutation = useMutation({
|
|
||||||
mutationFn: library.scan,
|
|
||||||
onMutate: () => {
|
|
||||||
setIsScanning(true)
|
|
||||||
toast.info('Scan Started', 'Scanning all folders for new photos...')
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Scan Complete', 'All folders have been scanned')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error('Scan Failed', error.message || 'Failed to scan folders')
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setIsScanning(false)
|
|
||||||
// Refetch photos after scan
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleAddFolder = async (path: string, recursive: boolean) => {
|
|
||||||
await addFolderMutation.mutateAsync({ path, recursive })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScanAll = () => {
|
|
||||||
scanLibraryMutation.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleExpanded = (id: string) => {
|
|
||||||
const newExpanded = new Set(expandedItems)
|
|
||||||
if (newExpanded.has(id)) {
|
|
||||||
newExpanded.delete(id)
|
|
||||||
} else {
|
|
||||||
newExpanded.add(id)
|
|
||||||
}
|
|
||||||
setExpandedItems(newExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryTree: TreeItem[] = [
|
|
||||||
{
|
|
||||||
id: 'library',
|
|
||||||
label: 'Library',
|
|
||||||
icon: <HardDrive className="h-4 w-4" />,
|
|
||||||
children: [
|
|
||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
|
||||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
|
||||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
|
||||||
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
|
||||||
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'folders',
|
|
||||||
label: 'Folders',
|
|
||||||
icon: <Folder className="h-4 w-4" />,
|
|
||||||
children: foldersData?.folders?.map((folder: any) => ({
|
|
||||||
id: `folder-${folder.id}`,
|
|
||||||
label: folder.name || folder.path.split('/').pop() || folder.path,
|
|
||||||
icon: <Folder className="h-4 w-4" />,
|
|
||||||
count: folder.photo_count,
|
|
||||||
type: 'folder',
|
|
||||||
})) || [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'heaps',
|
|
||||||
label: 'Heaps',
|
|
||||||
icon: <Folder className="h-4 w-4" />,
|
|
||||||
children: [], // Will be populated from API
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
|
||||||
const hasChildren = item.children && item.children.length > 0
|
|
||||||
const isExpanded = expandedItems.has(item.id)
|
|
||||||
const isSelected = selectedItem === item.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.id}>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
|
|
||||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
|
||||||
depth > 0 && 'text-[13px]'
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedItem(item.id)
|
|
||||||
if (hasChildren) {
|
|
||||||
toggleExpanded(item.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Expand/Collapse Icon */}
|
|
||||||
{hasChildren ? (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleExpanded(item.id)
|
|
||||||
}}
|
|
||||||
className="rounded p-0.5 hover:bg-surface-offset"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="w-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Item Icon */}
|
|
||||||
{item.icon && (
|
|
||||||
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<span className="flex-1 truncate">{item.label}</span>
|
|
||||||
|
|
||||||
{/* Count Badge */}
|
|
||||||
{item.count !== undefined && item.count > 0 && (
|
|
||||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
|
||||||
{item.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions (shown on hover) */}
|
|
||||||
{(item.id === 'folders' || item.id === 'heaps') && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
// Handle add folder/heap
|
|
||||||
}}
|
|
||||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{/* Sidebar Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
|
||||||
<h2 className="text-sm font-semibold text-text">Library</h2>
|
|
||||||
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tree View */}
|
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
|
||||||
<div className="border-t border-border p-3 space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddFolderDialog(true)}
|
|
||||||
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add Source Folder
|
|
||||||
</button>
|
|
||||||
{foldersData?.folders?.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleScanAll}
|
|
||||||
disabled={isScanning}
|
|
||||||
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx("h-4 w-4", isScanning && "animate-spin")} />
|
|
||||||
{isScanning ? 'Scanning...' : 'Scan All Folders'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Source Folder Dialog */}
|
|
||||||
<AddSourceFolderDialog
|
|
||||||
isOpen={showAddFolderDialog}
|
|
||||||
onClose={() => setShowAddFolderDialog(false)}
|
|
||||||
onAdd={handleAddFolder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Star,
|
|
||||||
MapPin,
|
|
||||||
Camera,
|
|
||||||
Aperture,
|
|
||||||
Info,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Check,
|
|
||||||
Plus
|
|
||||||
} from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
|
||||||
import { format } from 'date-fns'
|
|
||||||
|
|
||||||
export function RightSidebar() {
|
|
||||||
const { selectedPhotos, clearSelection } = usePhotoStore()
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
|
||||||
new Set(['basic', 'camera', 'location', 'tags'])
|
|
||||||
)
|
|
||||||
const [rating, setRating] = useState(0)
|
|
||||||
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
|
||||||
const newExpanded = new Set(expandedSections)
|
|
||||||
if (newExpanded.has(section)) {
|
|
||||||
newExpanded.delete(section)
|
|
||||||
} else {
|
|
||||||
newExpanded.add(section)
|
|
||||||
}
|
|
||||||
setExpandedSections(newExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock photo data - in real app, fetch based on selectedPhotos
|
|
||||||
const mockPhoto = selectedPhotos.length > 0 ? {
|
|
||||||
filename: 'IMG_1234.jpg',
|
|
||||||
size: '3.2 MB',
|
|
||||||
dimensions: '4032 × 3024',
|
|
||||||
dateTaken: new Date('2024-01-15T14:30:00'),
|
|
||||||
camera: 'Canon EOS R5',
|
|
||||||
lens: 'RF 24-70mm F2.8L IS USM',
|
|
||||||
iso: 400,
|
|
||||||
aperture: 'f/2.8',
|
|
||||||
shutterSpeed: '1/250',
|
|
||||||
focalLength: '50mm',
|
|
||||||
location: 'San Francisco, CA',
|
|
||||||
tags: ['landscape', 'sunset', 'golden hour'],
|
|
||||||
} : null
|
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
|
||||||
<div className="text-text-muted">
|
|
||||||
<Info className="mx-auto mb-2 h-8 w-8" />
|
|
||||||
<p className="text-sm">Select photos to view details</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const multipleSelected = selectedPhotos.length > 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col bg-surface">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
||||||
<h2 className="text-sm font-semibold text-text">
|
|
||||||
{multipleSelected
|
|
||||||
? `${selectedPhotos.length} Photos Selected`
|
|
||||||
: 'Photo Details'}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={clearSelection}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="border-b border-border p-4">
|
|
||||||
{/* Rating Stars */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<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={() => setRating(rating === value ? 0 : value)}
|
|
||||||
className="p-0.5"
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className={clsx(
|
|
||||||
'h-5 w-5 transition-colors',
|
|
||||||
value <= rating
|
|
||||||
? 'fill-star text-star'
|
|
||||||
: 'text-text-muted hover:text-star'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flag Status */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
|
||||||
flagStatus === 'pick'
|
|
||||||
? 'bg-pick/20 text-pick'
|
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
Pick
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
|
||||||
flagStatus === 'reject'
|
|
||||||
? 'bg-reject/20 text-reject'
|
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata Sections */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{mockPhoto && (
|
|
||||||
<>
|
|
||||||
{/* Basic Info */}
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('basic')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Basic Info</span>
|
|
||||||
{expandedSections.has('basic') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('basic') && (
|
|
||||||
<div className="px-4 pb-3 text-xs">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Filename:</span>
|
|
||||||
<p className="text-text">{mockPhoto.filename}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Size:</span>
|
|
||||||
<p className="text-text">{mockPhoto.size}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Dimensions:</span>
|
|
||||||
<p className="text-text">{mockPhoto.dimensions}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Date Taken:</span>
|
|
||||||
<p className="text-text">
|
|
||||||
{format(mockPhoto.dateTaken, 'MMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Camera Info */}
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('camera')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Camera</span>
|
|
||||||
{expandedSections.has('camera') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('camera') && (
|
|
||||||
<div className="px-4 pb-3 text-xs">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Camera className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">{mockPhoto.camera}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Aperture className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">{mockPhoto.lens}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">ISO:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.iso}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Aperture:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Shutter:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Focal:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('location')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Location</span>
|
|
||||||
{expandedSections.has('location') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('location') && (
|
|
||||||
<div className="px-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<MapPin className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">{mockPhoto.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('tags')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Tags</span>
|
|
||||||
{expandedSections.has('tags') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('tags') && (
|
|
||||||
<div className="px-4 pb-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{mockPhoto.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
|
||||||
{multipleSelected && (
|
|
||||||
<div className="border-t border-border p-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
|
||||||
Add to Heap
|
|
||||||
</button>
|
|
||||||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
|
||||||
Export Selected
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Grid,
|
|
||||||
List,
|
|
||||||
SlidersHorizontal,
|
|
||||||
FolderOpen,
|
|
||||||
Upload,
|
|
||||||
Settings,
|
|
||||||
Menu,
|
|
||||||
Trash2
|
|
||||||
} from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
|
||||||
import { photos } from '../../services/api'
|
|
||||||
import { toast } from '../ToastContainer'
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
|
||||||
|
|
||||||
export function TopBar() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
|
||||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
|
||||||
const clearSelection = usePhotoStore((state) => state.clearSelection)
|
|
||||||
const selectedCount = selectedPhotos.length
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// Mutation for moving photos to trash
|
|
||||||
const trashPhotosMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await photos.bulkUpdate(selectedPhotos, { trash: true })
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`)
|
|
||||||
clearSelection()
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error('Failed to Move to Trash', error.message || 'An error occurred')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
|
||||||
{/* Left Section - Menu and App Name */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
className="group relative rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Toggle sidebar (Tab)"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
|
||||||
Tab
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
|
||||||
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
|
||||||
</div>
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
|
|
||||||
{selectedCount} selected
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => trashPhotosMutation.mutate()}
|
|
||||||
disabled={trashPhotosMutation.isPending}
|
|
||||||
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
|
||||||
title="Move to trash"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Trash
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center Section - Search */}
|
|
||||||
<div className="flex max-w-xl flex-1 items-center px-8">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search photos..."
|
|
||||||
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-3 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Section - View Controls and Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<div className="flex rounded-md border border-border">
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
'rounded-l-md px-2 py-1',
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-surface text-text-muted hover:bg-surface-2'
|
|
||||||
)}
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
title="Grid view"
|
|
||||||
>
|
|
||||||
<Grid className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
'rounded-r-md px-2 py-1',
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-surface text-text-muted hover:bg-surface-2'
|
|
||||||
)}
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
title="List view"
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Button */}
|
|
||||||
<button
|
|
||||||
className="group relative rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Filter photos (Ctrl+F)"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
|
||||||
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
|
||||||
Ctrl+F
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mx-1 h-6 w-px bg-border" />
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<button
|
|
||||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Add folder"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Import photos"
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Star, Check, X } from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface Photo {
|
|
||||||
id: string
|
|
||||||
filepath: string
|
|
||||||
filename: string
|
|
||||||
width: number | null
|
|
||||||
height: number | null
|
|
||||||
taken_at: string | null
|
|
||||||
rating: number
|
|
||||||
is_picked: boolean
|
|
||||||
is_rejected: boolean
|
|
||||||
file_hash: string
|
|
||||||
media_type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhotoThumbnailProps {
|
|
||||||
photo: Photo
|
|
||||||
size: number
|
|
||||||
isSelected: boolean
|
|
||||||
onClick: (e: React.MouseEvent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
|
|
||||||
const [imageError, setImageError] = useState(false)
|
|
||||||
const [imageLoaded, setImageLoaded] = useState(false)
|
|
||||||
|
|
||||||
// Generate thumbnail URL - assuming backend serves thumbnails at /api/photos/{id}/thumbnail
|
|
||||||
const thumbnailUrl = `http://localhost:8001/api/v1/photos/${photo.id}/thumb/medium`
|
|
||||||
|
|
||||||
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
|
|
||||||
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
|
|
||||||
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
|
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
|
||||||
setImageLoaded(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
setImageError(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset state when photo changes
|
|
||||||
useEffect(() => {
|
|
||||||
setImageError(false)
|
|
||||||
setImageLoaded(false)
|
|
||||||
}, [photo.id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
|
||||||
'hover:ring-2 hover:ring-primary/50',
|
|
||||||
isSelected && 'ring-2 ring-primary shadow-lg',
|
|
||||||
!imageLoaded && 'bg-surface animate-pulse'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: displayHeight,
|
|
||||||
}}
|
|
||||||
onClick={onClick}
|
|
||||||
title="Click to select • Shift+Click for range • Ctrl+Click 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'
|
|
||||||
)}
|
|
||||||
onLoad={handleImageLoad}
|
|
||||||
onError={handleImageError}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
|
|
||||||
<div className="text-center text-xs">
|
|
||||||
<div>Unable to load</div>
|
|
||||||
<div className="mt-1 font-mono text-[10px]">{photo.filename}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selection Indicator */}
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rating Stars */}
|
|
||||||
{photo.rating > 0 && (
|
|
||||||
<div className="absolute bottom-1 left-1 flex gap-0.5">
|
|
||||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
className="h-3 w-3 fill-star text-star"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Flag Indicators */}
|
|
||||||
<div className="absolute bottom-1 right-1">
|
|
||||||
{photo.is_picked && (
|
|
||||||
<Check className="h-4 w-4 text-pick" />
|
|
||||||
)}
|
|
||||||
{photo.is_rejected && (
|
|
||||||
<X className="h-4 w-4 text-reject" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Type Badge for RAW/Video */}
|
|
||||||
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
|
|
||||||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
|
|
||||||
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
|
|
||||||
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
|
|
||||||
interface Photo {
|
|
||||||
id: string
|
|
||||||
filepath: string
|
|
||||||
filename: string
|
|
||||||
width: number | null
|
|
||||||
height: number | null
|
|
||||||
taken_at: string | null
|
|
||||||
rating: number
|
|
||||||
is_picked: boolean
|
|
||||||
is_rejected: boolean
|
|
||||||
file_hash: string
|
|
||||||
media_type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Timeline() {
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [containerWidth, setContainerWidth] = useState(0)
|
|
||||||
|
|
||||||
const {
|
|
||||||
selectedPhotos,
|
|
||||||
lastSelectedIndex,
|
|
||||||
rangeStartIndex,
|
|
||||||
selectPhoto,
|
|
||||||
togglePhotoSelection,
|
|
||||||
clearSelection,
|
|
||||||
|
|
||||||
} = usePhotoStore()
|
|
||||||
|
|
||||||
// Helper function for range selection
|
|
||||||
const selectRange = (endIndex: number) => {
|
|
||||||
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
|
|
||||||
const minIndex = Math.min(startIndex, endIndex)
|
|
||||||
const maxIndex = Math.max(startIndex, endIndex)
|
|
||||||
|
|
||||||
// Select all photos in the range
|
|
||||||
for (let i = minIndex; i <= maxIndex; i++) {
|
|
||||||
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
|
|
||||||
togglePhotoSelection(photos[i].id, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thumbnail size configuration
|
|
||||||
const thumbnailSize = 200 // Base size for thumbnails
|
|
||||||
const gap = 4
|
|
||||||
const padding = 16
|
|
||||||
|
|
||||||
// Calculate number of columns based on container width
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
if (containerWidth === 0) return 4
|
|
||||||
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
|
||||||
}, [containerWidth, thumbnailSize, gap, padding])
|
|
||||||
|
|
||||||
// Fetch photos from backend
|
|
||||||
const { data: photos = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['photos'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
|
|
||||||
params: {
|
|
||||||
limit: 1000,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return response.data.photos || []
|
|
||||||
},
|
|
||||||
staleTime: 30000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group photos into rows for grid layout
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const result: Photo[][] = []
|
|
||||||
for (let i = 0; i < photos.length; i += columns) {
|
|
||||||
result.push(photos.slice(i, i + columns))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}, [photos, columns])
|
|
||||||
|
|
||||||
// Virtual scrolling setup
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: rows.length,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
estimateSize: () => thumbnailSize + gap,
|
|
||||||
overscan: 5,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Measure container width on mount and resize
|
|
||||||
useEffect(() => {
|
|
||||||
const measureWidth = () => {
|
|
||||||
if (parentRef.current) {
|
|
||||||
setContainerWidth(parentRef.current.clientWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
measureWidth()
|
|
||||||
window.addEventListener('resize', measureWidth)
|
|
||||||
return () => window.removeEventListener('resize', measureWidth)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Handle keyboard shortcuts for photo navigation
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (photos.length === 0) return
|
|
||||||
|
|
||||||
const currentIndex = lastSelectedIndex ?? -1
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
if (currentIndex > columns - 1) {
|
|
||||||
const newIndex = currentIndex - columns
|
|
||||||
if (e.shiftKey) {
|
|
||||||
selectRange(newIndex)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
if (currentIndex < photos.length - columns) {
|
|
||||||
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
|
||||||
if (e.shiftKey) {
|
|
||||||
selectRange(newIndex)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault()
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
const newIndex = currentIndex - 1
|
|
||||||
if (e.shiftKey) {
|
|
||||||
selectRange(newIndex)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault()
|
|
||||||
if (currentIndex < photos.length - 1) {
|
|
||||||
const newIndex = currentIndex + 1
|
|
||||||
if (e.shiftKey) {
|
|
||||||
selectRange(newIndex)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'a':
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
// Select all
|
|
||||||
photos.forEach((photo, index) => {
|
|
||||||
if (!selectedPhotos.includes(photo.id)) {
|
|
||||||
togglePhotoSelection(photo.id, index)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
clearSelection()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection])
|
|
||||||
|
|
||||||
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">No photos found. Add a source folder to get started.</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={parentRef}
|
|
||||||
className="h-full overflow-auto bg-bg"
|
|
||||||
style={{ padding: `${padding}px` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
|
||||||
width: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index]
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualRow.key}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: `${virtualRow.size}px`,
|
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex"
|
|
||||||
style={{ gap: `${gap}px` }}
|
|
||||||
>
|
|
||||||
{row.map((photo, colIndex) => {
|
|
||||||
const globalIndex = virtualRow.index * columns + colIndex
|
|
||||||
return (
|
|
||||||
<PhotoThumbnail
|
|
||||||
key={photo.id}
|
|
||||||
photo={photo}
|
|
||||||
size={thumbnailSize}
|
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
|
||||||
selectRange(globalIndex)
|
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
|
||||||
togglePhotoSelection(photo.id, globalIndex)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photo.id, globalIndex)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
|
||||||
onToggleLeftSidebar: () => void
|
|
||||||
onToggleRightSidebar: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|
||||||
const { onToggleLeftSidebar, onToggleRightSidebar } = props
|
|
||||||
|
|
||||||
// Toggle sidebars
|
|
||||||
useHotkeys('tab', (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
onToggleLeftSidebar()
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeys('i', (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
onToggleRightSidebar()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Navigation shortcuts
|
|
||||||
useHotkeys('g', () => {
|
|
||||||
// Go to grid view
|
|
||||||
console.log('Grid view')
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeys('e', () => {
|
|
||||||
// Go to loupe view
|
|
||||||
console.log('Loupe view')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Rating shortcuts
|
|
||||||
useHotkeys('1,2,3,4,5', (_e, handler) => {
|
|
||||||
const rating = parseInt(handler.keys![0])
|
|
||||||
console.log('Set rating:', rating)
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeys('0', () => {
|
|
||||||
console.log('Remove rating')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Flag shortcuts
|
|
||||||
useHotkeys('p', () => {
|
|
||||||
console.log('Pick photo')
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeys('x', () => {
|
|
||||||
console.log('Reject photo')
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeys('u', () => {
|
|
||||||
console.log('Unflag photo')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--color-bg: #111110;
|
|
||||||
--color-surface: #161615;
|
|
||||||
--color-surface-2: #1c1c1a;
|
|
||||||
--color-surface-offset: #222220;
|
|
||||||
--color-border: rgba(255, 255, 255, 0.08);
|
|
||||||
--color-text: #e8e6e0;
|
|
||||||
--color-text-muted: #878580;
|
|
||||||
--color-text-faint: #4a4845;
|
|
||||||
--color-primary: #4f98a3;
|
|
||||||
--color-pick: #4f9e5c;
|
|
||||||
--color-reject: #c25a5a;
|
|
||||||
--color-star: #d4a340;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-bg text-text;
|
|
||||||
font-family: 'Geist', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar styles */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
@apply bg-surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-surface-offset rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
@apply bg-text-faint;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|
||||||
import App from './App'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
retry: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost:8001/api/v1'
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: API_BASE_URL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Source Folders API
|
|
||||||
export const sourceFolders = {
|
|
||||||
list: async () => {
|
|
||||||
const response = await api.get('/folders')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
add: async (path: string, recursive: boolean = true) => {
|
|
||||||
const response = await api.post('/folders', {
|
|
||||||
path,
|
|
||||||
recursive,
|
|
||||||
watch: false, // Can be made configurable later
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
scan: async (folderId: string) => {
|
|
||||||
const response = await api.post(`/folders/${folderId}/scan`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async (folderId: string) => {
|
|
||||||
const response = await api.delete(`/folders/${folderId}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Photos API
|
|
||||||
export const photos = {
|
|
||||||
list: async (params?: {
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
folder_id?: string
|
|
||||||
heap_id?: string
|
|
||||||
rating?: number
|
|
||||||
flag?: string
|
|
||||||
}) => {
|
|
||||||
const response = await api.get('/photos', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
get: async (photoId: string) => {
|
|
||||||
const response = await api.get(`/photos/${photoId}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async (photoId: string, data: {
|
|
||||||
rating?: number
|
|
||||||
flag?: string
|
|
||||||
user_title?: string
|
|
||||||
user_notes?: string
|
|
||||||
}) => {
|
|
||||||
const response = await api.patch(`/photos/${photoId}`, data)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
bulkUpdate: async (photoIds: string[], data: {
|
|
||||||
rating?: number
|
|
||||||
flag?: string
|
|
||||||
heap_id?: string
|
|
||||||
trash?: boolean
|
|
||||||
}) => {
|
|
||||||
const response = await api.post('/photos/bulk', {
|
|
||||||
photo_ids: photoIds,
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
|
|
||||||
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
|
||||||
},
|
|
||||||
|
|
||||||
getOriginalUrl: (photoId: string) => {
|
|
||||||
return `${API_BASE_URL}/photos/${photoId}/original`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Library API
|
|
||||||
export const library = {
|
|
||||||
scan: async () => {
|
|
||||||
const response = await api.post('/library/scan')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
scanStatus: async () => {
|
|
||||||
const response = await api.get('/library/scan/status')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
stats: async () => {
|
|
||||||
const response = await api.get('/library/stats')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heaps API
|
|
||||||
export const heaps = {
|
|
||||||
list: async () => {
|
|
||||||
const response = await api.get('/heaps')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
create: async (name: string, description?: string) => {
|
|
||||||
const response = await api.post('/heaps', {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async (heapId: string, data: {
|
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
}) => {
|
|
||||||
const response = await api.patch(`/heaps/${heapId}`, data)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async (heapId: string) => {
|
|
||||||
const response = await api.delete(`/heaps/${heapId}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags API
|
|
||||||
export const tags = {
|
|
||||||
list: async () => {
|
|
||||||
const response = await api.get('/tags')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
create: async (name: string, color?: string) => {
|
|
||||||
const response = await api.post('/tags', {
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async (tagId: string, data: {
|
|
||||||
name?: string
|
|
||||||
color?: string
|
|
||||||
}) => {
|
|
||||||
const response = await api.patch(`/tags/${tagId}`, data)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async (tagId: string) => {
|
|
||||||
const response = await api.delete(`/tags/${tagId}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trash API
|
|
||||||
export const trash = {
|
|
||||||
list: async () => {
|
|
||||||
const response = await api.get('/trash')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
restore: async (photoIds: string[]) => {
|
|
||||||
const response = await api.post('/trash/restore', {
|
|
||||||
photo_ids: photoIds,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
empty: async () => {
|
|
||||||
const response = await api.delete('/trash/empty')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default api
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
|
|
||||||
interface Photo {
|
|
||||||
id: string
|
|
||||||
filename: string
|
|
||||||
filepath: string
|
|
||||||
media_type: string
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
taken_at?: string
|
|
||||||
thumb_small?: string
|
|
||||||
thumb_medium?: string
|
|
||||||
thumb_large?: string
|
|
||||||
rating: number
|
|
||||||
is_picked: boolean
|
|
||||||
is_rejected: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhotoStore {
|
|
||||||
photos: Photo[]
|
|
||||||
selectedPhotos: string[]
|
|
||||||
activePhotoId: string | null
|
|
||||||
lastSelectedIndex: number | null
|
|
||||||
rangeStartIndex: number | null
|
|
||||||
|
|
||||||
setPhotos: (photos: Photo[]) => void
|
|
||||||
selectPhoto: (id: string, index: number) => void
|
|
||||||
togglePhotoSelection: (id: string, index: number) => void
|
|
||||||
selectRange: (endIndex: number) => void
|
|
||||||
deselectPhoto: (id: string) => void
|
|
||||||
clearSelection: () => void
|
|
||||||
setActivePhoto: (id: string | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePhotoStore = create<PhotoStore>((set) => ({
|
|
||||||
photos: [],
|
|
||||||
selectedPhotos: [],
|
|
||||||
activePhotoId: null,
|
|
||||||
lastSelectedIndex: null,
|
|
||||||
rangeStartIndex: null,
|
|
||||||
|
|
||||||
setPhotos: (photos) => set({ photos }),
|
|
||||||
|
|
||||||
selectPhoto: (id, index) => set({
|
|
||||||
selectedPhotos: [id],
|
|
||||||
activePhotoId: id,
|
|
||||||
lastSelectedIndex: index,
|
|
||||||
rangeStartIndex: index,
|
|
||||||
}),
|
|
||||||
|
|
||||||
togglePhotoSelection: (id, index) => set((state) => {
|
|
||||||
const isSelected = state.selectedPhotos.includes(id)
|
|
||||||
return {
|
|
||||||
selectedPhotos: isSelected
|
|
||||||
? state.selectedPhotos.filter(photoId => photoId !== id)
|
|
||||||
: [...state.selectedPhotos, id],
|
|
||||||
lastSelectedIndex: index,
|
|
||||||
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
selectRange: (endIndex) => {
|
|
||||||
// Note: The actual range selection logic should be handled in the Timeline component
|
|
||||||
// which has access to the photos array
|
|
||||||
set({
|
|
||||||
lastSelectedIndex: endIndex,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
deselectPhoto: (id) => set((state) => ({
|
|
||||||
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
|
|
||||||
})),
|
|
||||||
|
|
||||||
clearSelection: () => set({
|
|
||||||
selectedPhotos: [],
|
|
||||||
lastSelectedIndex: null,
|
|
||||||
rangeStartIndex: null,
|
|
||||||
}),
|
|
||||||
|
|
||||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
|
||||||
}))
|
|
||||||
29
frontend/src/types/images.d.ts
vendored
29
frontend/src/types/images.d.ts
vendored
@@ -1,29 +0,0 @@
|
|||||||
declare module '*.png' {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.jpg' {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.jpeg' {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.gif' {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.svg' {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.webp' {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
// Dark-first color scheme for photo apps
|
|
||||||
bg: '#111110',
|
|
||||||
surface: '#161615',
|
|
||||||
'surface-2': '#1c1c1a',
|
|
||||||
'surface-offset': '#222220',
|
|
||||||
border: 'rgba(255,255,255,0.08)',
|
|
||||||
text: '#e8e6e0',
|
|
||||||
'text-muted': '#878580',
|
|
||||||
'text-faint': '#4a4845',
|
|
||||||
primary: '#4f98a3', // desaturated teal
|
|
||||||
pick: '#4f9e5c', // green for picked
|
|
||||||
reject: '#c25a5a', // red for rejected
|
|
||||||
star: '#d4a340', // amber for stars
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Geist', 'system-ui', 'sans-serif'],
|
|
||||||
mono: ['Geist Mono', 'monospace'],
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'fade-in': 'fadeIn 0.2s ease-in-out',
|
|
||||||
'slide-up': 'slideUp 0.3s ease-out',
|
|
||||||
'shimmer': 'shimmer 2s infinite linear',
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
fadeIn: {
|
|
||||||
'0%': { opacity: '0' },
|
|
||||||
'100%': { opacity: '1' },
|
|
||||||
},
|
|
||||||
slideUp: {
|
|
||||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
||||||
},
|
|
||||||
shimmer: {
|
|
||||||
'0%': { backgroundPosition: '-200% 0' },
|
|
||||||
'100%': { backgroundPosition: '200% 0' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
|
|
||||||
/* Path mapping */
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:8000',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
25
mariadb/init/01-sidecar.sql
Normal file
25
mariadb/init/01-sidecar.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Bootstraps the `mule_sidecar` database + user used by the Go sidecar
|
||||||
|
-- service (per-user heap sharing + folder mutations, stood up in M4).
|
||||||
|
--
|
||||||
|
-- MariaDB runs every .sql in /docker-entrypoint-initdb.d ONCE, on first
|
||||||
|
-- boot of a fresh data volume. Subsequent boots are no-ops.
|
||||||
|
--
|
||||||
|
-- The sidecar's MariaDB user is intentionally scoped to `mule_sidecar.*`
|
||||||
|
-- only — it never has access to PhotoPrism's schema.
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS mule_sidecar
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- The password here is substituted at compose build time via envsubst,
|
||||||
|
-- but MariaDB's init script doesn't expand vars in .sql files. So we use
|
||||||
|
-- a literal placeholder that the user replaces locally — or, simpler,
|
||||||
|
-- we let the M4 sidecar bring-up script create the user via SQL with the
|
||||||
|
-- env-var password. Keeping a placeholder here makes the schema visible
|
||||||
|
-- in source control without leaking creds.
|
||||||
|
--
|
||||||
|
-- TODO (M4): replace this block with an entrypoint that templates the
|
||||||
|
-- password from $SIDECAR_DB_PASSWORD before MariaDB reads the file.
|
||||||
|
CREATE USER IF NOT EXISTS 'sidecar'@'%' IDENTIFIED BY 'replace-at-m4-bringup';
|
||||||
|
GRANT ALL PRIVILEGES ON mule_sidecar.* TO 'sidecar'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
30
mulita.yml
30
mulita.yml
@@ -1,30 +0,0 @@
|
|||||||
# Mulita configuration file
|
|
||||||
|
|
||||||
source_roots:
|
|
||||||
- name: "Main Library"
|
|
||||||
path: /photos/main
|
|
||||||
- name: "iPhone Imports"
|
|
||||||
path: /photos/iphone
|
|
||||||
|
|
||||||
thumbnails:
|
|
||||||
small: 240 # px, longest edge
|
|
||||||
medium: 640
|
|
||||||
large: 1280
|
|
||||||
quality: 85 # JPEG/WebP quality
|
|
||||||
format: webp # output format for thumbs
|
|
||||||
|
|
||||||
scanner:
|
|
||||||
watch: true # use watchfiles inotify
|
|
||||||
initial_scan_on_start: true
|
|
||||||
batch_size: 100
|
|
||||||
concurrent_workers: 4
|
|
||||||
|
|
||||||
trash:
|
|
||||||
path: /data/trash
|
|
||||||
auto_empty_days: 30 # auto-delete after 30 days in trash
|
|
||||||
|
|
||||||
performance:
|
|
||||||
max_concurrent_thumbnails: 10
|
|
||||||
cache_ttl: 3600
|
|
||||||
db_pool_size: 20
|
|
||||||
db_pool_recycle: 3600
|
|
||||||
@@ -1,753 +0,0 @@
|
|||||||
# PhotoVault — Full Application Spec Prompt
|
|
||||||
|
|
||||||
> A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow.
|
|
||||||
> Use this document as the complete specification to build the app from scratch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Project Overview
|
|
||||||
|
|
||||||
Build **PhotoVault**, a self-hosted photo & video management web application optimized for a single-user homelab deployment. The user mounts one or more host folders containing photos/videos; the app indexes them, generates thumbnails, and provides a fast keyboard-driven interface to browse, organize, tag, and manage the library. The architecture must be forward-compatible with AI photo recognition features (face detection, scene classification, CLIP embeddings) to be added in a later phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Stack & Deployment
|
|
||||||
|
|
||||||
### 2.1 Docker Compose (single `docker-compose.yml`)
|
|
||||||
|
|
||||||
```
|
|
||||||
services:
|
|
||||||
frontend — React SPA (Nginx)
|
|
||||||
backend — Python FastAPI
|
|
||||||
db — SQLite (file-based, volume-mounted)
|
|
||||||
worker — Celery + Redis for background thumbnail/indexing tasks
|
|
||||||
redis — Redis (Celery broker)
|
|
||||||
```
|
|
||||||
|
|
||||||
All services declared in one `docker-compose.yml`. Use named volumes for:
|
|
||||||
- `/data/thumbs` — generated thumbnails (persistent)
|
|
||||||
- `/data/db` — SQLite database file
|
|
||||||
- `/data/trash` — files moved to trash
|
|
||||||
|
|
||||||
Photo source folders are mounted as **read-write** bind mounts via an environment variable:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- ${PHOTO_DIRS}:/photos:rw
|
|
||||||
```
|
|
||||||
|
|
||||||
`PHOTO_DIRS` supports multiple paths via a config file (`photovault.yml`) described in §4.
|
|
||||||
|
|
||||||
### 2.2 Frontend
|
|
||||||
|
|
||||||
- **React 18** + **Vite**
|
|
||||||
- **Tailwind CSS v4**
|
|
||||||
- **shadcn/ui** component library
|
|
||||||
- **TanStack Query** (React Query) for data fetching & cache
|
|
||||||
- **TanStack Virtual** for virtualized scrolling (critical for performance with thousands of photos)
|
|
||||||
- **Zustand** for global UI state (selection, active photo, heap, filters)
|
|
||||||
- **Framer Motion** for transitions
|
|
||||||
|
|
||||||
### 2.3 Backend
|
|
||||||
|
|
||||||
- **Python 3.12 + FastAPI**
|
|
||||||
- **SQLite** via **SQLAlchemy 2.0** (async) + **Alembic** for migrations
|
|
||||||
- **Celery + Redis** for background tasks (thumbnail generation, folder scanning, metadata extraction)
|
|
||||||
- **pyvips** (libvips) for fast thumbnail generation — preferred over Pillow for speed at scale
|
|
||||||
- **rawpy** for RAW format decoding (CR2, CR3, NEF, ARW, RAF, DNG, ORF, RW2, etc.)
|
|
||||||
- **pillow-heif** for HEIC/HEIF (iPhone photos)
|
|
||||||
- **ffmpeg** (via `ffmpeg-python`) for video thumbnail extraction and metadata
|
|
||||||
- **pyexiftool** (wraps ExifTool binary) for deep metadata extraction from all formats
|
|
||||||
- **Watchfiles** for inotify-based folder watching (auto-detect new/deleted files)
|
|
||||||
|
|
||||||
> **AI-readiness note**: The backend worker architecture is designed to add a `clip_embed` task later (using `open-clip-torch`) that stores 512-dim CLIP embeddings per photo in the DB. Reserve a `embeddings` table with a `photo_id` FK and a `BLOB` column for the vector. No AI code yet — just the schema placeholder.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Data Model (SQLite via SQLAlchemy)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Core tables
|
|
||||||
|
|
||||||
photos (
|
|
||||||
id TEXT PRIMARY KEY, -- UUID
|
|
||||||
filepath TEXT UNIQUE NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
folder_id TEXT REFERENCES folders(id),
|
|
||||||
media_type TEXT NOT NULL, -- 'photo' | 'video' | 'raw' | 'heic'
|
|
||||||
original_format TEXT, -- 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
file_size INTEGER,
|
|
||||||
taken_at DATETIME, -- from EXIF DateTimeOriginal, fallback to file mtime
|
|
||||||
taken_at_source TEXT, -- 'exif' | 'filesystem' | 'manual'
|
|
||||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME,
|
|
||||||
is_trashed BOOLEAN DEFAULT 0,
|
|
||||||
trashed_at DATETIME,
|
|
||||||
thumb_small TEXT, -- path to 240px thumb
|
|
||||||
thumb_medium TEXT, -- path to 640px thumb
|
|
||||||
thumb_large TEXT, -- path to 1280px thumb
|
|
||||||
exif_json TEXT, -- full EXIF/XMP blob as JSON
|
|
||||||
user_title TEXT, -- user-edited title
|
|
||||||
user_notes TEXT,
|
|
||||||
rating INTEGER DEFAULT 0, -- 0-5 stars
|
|
||||||
color_label TEXT, -- 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
|
||||||
is_picked BOOLEAN DEFAULT 0,
|
|
||||||
is_rejected BOOLEAN DEFAULT 0
|
|
||||||
)
|
|
||||||
|
|
||||||
folders (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT UNIQUE NOT NULL,
|
|
||||||
parent_id TEXT REFERENCES folders(id),
|
|
||||||
source_root_id TEXT REFERENCES source_roots(id),
|
|
||||||
photo_count INTEGER DEFAULT 0,
|
|
||||||
last_scanned DATETIME
|
|
||||||
)
|
|
||||||
|
|
||||||
source_roots (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT UNIQUE NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT 1,
|
|
||||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
|
|
||||||
tags (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT UNIQUE NOT NULL,
|
|
||||||
color TEXT
|
|
||||||
)
|
|
||||||
|
|
||||||
photo_tags (
|
|
||||||
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
|
|
||||||
tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (photo_id, tag_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
heaps (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME
|
|
||||||
)
|
|
||||||
|
|
||||||
heap_photos (
|
|
||||||
heap_id TEXT REFERENCES heaps(id) ON DELETE CASCADE,
|
|
||||||
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
|
|
||||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (heap_id, photo_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
-- AI-readiness placeholder (no implementation yet)
|
|
||||||
embeddings (
|
|
||||||
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
|
|
||||||
model TEXT, -- e.g. 'clip-vit-b32'
|
|
||||||
vector BLOB -- raw float32 bytes
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Indexes**: Create indexes on `photos.taken_at`, `photos.folder_id`, `photos.is_trashed`, `photos.rating`, `photos.color_label`, `photo_tags.tag_id`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Configuration
|
|
||||||
|
|
||||||
App is configured via a `photovault.yml` file mounted into the backend container:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
source_roots:
|
|
||||||
- name: "Main Library"
|
|
||||||
path: /photos/main
|
|
||||||
- name: "iPhone Imports"
|
|
||||||
path: /photos/iphone
|
|
||||||
|
|
||||||
thumbnails:
|
|
||||||
small: 240 # px, longest edge
|
|
||||||
medium: 640
|
|
||||||
large: 1280
|
|
||||||
quality: 85 # JPEG quality
|
|
||||||
format: webp # output format for thumbs
|
|
||||||
|
|
||||||
scanner:
|
|
||||||
watch: true # use watchfiles inotify
|
|
||||||
initial_scan_on_start: true
|
|
||||||
|
|
||||||
trash:
|
|
||||||
path: /data/trash
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Backend API (FastAPI)
|
|
||||||
|
|
||||||
All routes under `/api/v1/`. Authentication: none (single-user, homelab). Use async SQLAlchemy sessions.
|
|
||||||
|
|
||||||
### 5.1 Photos
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /photos List photos (pagination + filters — see §5.5)
|
|
||||||
GET /photos/{id} Get single photo with full EXIF + tags
|
|
||||||
GET /photos/{id}/thumb/{size} Serve thumbnail (small|medium|large) — use X-Accel-Redirect for Nginx
|
|
||||||
GET /photos/{id}/original Serve original file (download)
|
|
||||||
PATCH /photos/{id} Update: user_title, user_notes, rating, color_label, is_picked, is_rejected, taken_at (manual override)
|
|
||||||
DELETE /photos/{id} Move to trash (sets is_trashed=1, moves file to /data/trash)
|
|
||||||
POST /photos/bulk Bulk actions: { ids: [], action: 'trash'|'restore'|'delete_permanent'|'move'|'copy'|'add_tag'|'remove_tag'|'set_rating'|'set_color'|'pick'|'reject' }
|
|
||||||
POST /photos/bulk/move Move files to a target folder_id
|
|
||||||
POST /photos/bulk/copy Copy files to a target folder_id
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Folders
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /folders Folder tree (nested, with photo_count)
|
|
||||||
GET /folders/{id}/photos Photos in folder (supports same filters as /photos)
|
|
||||||
POST /folders Create folder (creates directory on disk)
|
|
||||||
PATCH /folders/{id} Rename folder (renames directory on disk)
|
|
||||||
DELETE /folders/{id} Delete folder — requires folder to be empty
|
|
||||||
POST /folders/{id}/scan Trigger manual re-scan of folder
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Heaps
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /heaps List all heaps
|
|
||||||
POST /heaps Create heap { name }
|
|
||||||
GET /heaps/{id} Get heap with photos
|
|
||||||
PATCH /heaps/{id} Rename heap
|
|
||||||
DELETE /heaps/{id} Delete heap (does NOT delete photos)
|
|
||||||
POST /heaps/{id}/photos Add photos { photo_ids: [] }
|
|
||||||
DELETE /heaps/{id}/photos Remove photos { photo_ids: [] }
|
|
||||||
POST /heaps/{id}/convert Convert heap to folder on disk: { target_path, move: bool }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 Tags
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /tags List all tags with usage counts
|
|
||||||
POST /tags Create tag
|
|
||||||
PATCH /tags/{id} Rename / recolor tag
|
|
||||||
DELETE /tags/{id} Delete tag (removes from all photos)
|
|
||||||
GET /tags/{id}/photos Photos with this tag
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.5 Filters & Search
|
|
||||||
|
|
||||||
All list endpoints support these query parameters:
|
|
||||||
|
|
||||||
```
|
|
||||||
q Full-text search (filename, user_title, user_notes, EXIF JSON)
|
|
||||||
date_from ISO8601 datetime
|
|
||||||
date_to ISO8601 datetime
|
|
||||||
folder_id Filter by folder (recursive if include_subfolders=true)
|
|
||||||
tag_ids Comma-separated tag IDs (AND logic by default; mode=or for OR)
|
|
||||||
media_type photo|video|raw|heic (comma-separated for multiple)
|
|
||||||
rating_min 0-5
|
|
||||||
rating_max 0-5
|
|
||||||
color_label red|orange|yellow|green|blue|purple|none
|
|
||||||
is_picked true|false
|
|
||||||
is_rejected true|false
|
|
||||||
is_trashed true|false (default false)
|
|
||||||
heap_id Filter to photos in a specific heap
|
|
||||||
sort taken_at|added_at|filename|file_size|rating (default taken_at)
|
|
||||||
order asc|desc (default desc)
|
|
||||||
page integer (default 1)
|
|
||||||
per_page integer (default 100, max 500)
|
|
||||||
```
|
|
||||||
|
|
||||||
Full-text search uses SQLite FTS5. Create a virtual FTS table:
|
|
||||||
```sql
|
|
||||||
CREATE VIRTUAL TABLE photos_fts USING fts5(
|
|
||||||
photo_id UNINDEXED,
|
|
||||||
filename,
|
|
||||||
user_title,
|
|
||||||
user_notes,
|
|
||||||
exif_text -- denormalized key EXIF fields as plain text (camera make/model, GPS, lens, etc.)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.6 Trash
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /trash List trashed photos (same filters)
|
|
||||||
POST /trash/restore Restore { photo_ids: [] } — moves files back to original folder
|
|
||||||
DELETE /trash/empty Permanently delete all trashed photos + files
|
|
||||||
DELETE /trash/{id} Permanently delete single photo + file
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.7 Library Stats & Scanning
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /library/stats { total_photos, total_videos, total_size, last_scan }
|
|
||||||
POST /library/scan Trigger full re-scan (Celery task)
|
|
||||||
GET /library/scan/status { status, progress, current_folder, queued, done }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.8 Background Tasks (Celery)
|
|
||||||
|
|
||||||
- `scan_folder(folder_path)` — Walk directory tree, insert/update photos, detect deletions
|
|
||||||
- `generate_thumbs(photo_id)` — Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpeg
|
|
||||||
- `extract_metadata(photo_id)` — Run ExifTool, parse EXIF/XMP/IPTC, update DB
|
|
||||||
- `watch_folders()` — Long-running Watchfiles task, dispatches scan_folder on changes
|
|
||||||
- `embed_photo(photo_id)` *(placeholder, no-op)* — Reserved for CLIP embeddings
|
|
||||||
|
|
||||||
**Priority queues**: Thumbnail generation for visible photos should be on a `high` queue; full library scans on a `low` queue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Frontend Architecture
|
|
||||||
|
|
||||||
### 6.1 Layout
|
|
||||||
|
|
||||||
Three-pane layout (similar to Lightroom Library module):
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ TOP BAR [Logo] [Search] [Filters bar] [View mode] [Heap] │
|
|
||||||
├──────────┬──────────────────────────────────┬───────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ LEFT │ MAIN TIMELINE │ RIGHT │
|
|
||||||
│ SIDEBAR │ (continuous scroll, │ SIDEBAR │
|
|
||||||
│ │ sticky date headers, │ (metadata │
|
|
||||||
│ Folder │ virtualized thumbnail │ panel for │
|
|
||||||
│ tree │ grid) │ selected │
|
|
||||||
│ │ │ photo) │
|
|
||||||
│ Heaps │ │ │
|
|
||||||
│ list │ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ Tags │ │ │
|
|
||||||
└──────────┴──────────────────────────────────┴───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- Left sidebar: resizable, collapsible (shortcut: `Tab`)
|
|
||||||
- Right sidebar: collapsible (shortcut: `I`), shows when ≥1 photo selected
|
|
||||||
- Main area: full virtualized scroll, single scroll region
|
|
||||||
|
|
||||||
### 6.2 Views
|
|
||||||
|
|
||||||
| View | Shortcut | Description |
|
|
||||||
|------|----------|-------------|
|
|
||||||
| Grid (Library) | `G` | Default timeline thumbnail grid |
|
|
||||||
| Loupe (Fullscreen) | `E` | Single photo full-viewport view |
|
|
||||||
| Compare | `C` | Side-by-side compare of 2 selected photos |
|
|
||||||
|
|
||||||
### 6.3 Timeline View (Grid)
|
|
||||||
|
|
||||||
- **Continuous vertical scroll** with **sticky date headers** that label each date group (Year / Month / Day — configurable via a "Group by" dropdown: Year, Month, Day, Week, Folder)
|
|
||||||
- Thumbnails rendered via **TanStack Virtual** — only DOM nodes in/near viewport are rendered
|
|
||||||
- Thumbnail grid is **responsive** — uses CSS grid with `auto-fill` and configurable thumbnail size (slider or `+/-` keys)
|
|
||||||
- Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars
|
|
||||||
- **Lazy thumbnail loading**: request `thumb_small` initially; upgrade to `thumb_medium` on hover/selection
|
|
||||||
- On initial scan, show a shimmer skeleton for photos without thumbnails yet; poll backend for thumb completion
|
|
||||||
|
|
||||||
### 6.4 Keyboard Shortcuts (Lightroom-style)
|
|
||||||
|
|
||||||
#### Navigation (Grid mode)
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `←` `→` `↑` `↓` | Move cursor one photo in direction |
|
|
||||||
| `Shift+←/→/↑/↓` | Extend selection |
|
|
||||||
| `Cmd/Ctrl+A` | Select all |
|
|
||||||
| `Cmd/Ctrl+Shift+A` | Deselect all |
|
|
||||||
| `Space` | Quick preview (fullscreen loupe, hold) |
|
|
||||||
| `Enter` | Open loupe view |
|
|
||||||
| `Esc` | Deselect / close loupe |
|
|
||||||
| `Home` / `End` | Jump to first / last photo |
|
|
||||||
| `Page Up/Down` | Scroll by screen height |
|
|
||||||
|
|
||||||
#### Navigation (Loupe mode)
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `←` `→` | Previous / next photo |
|
|
||||||
| `Esc` | Return to grid |
|
|
||||||
| `Z` | Toggle zoom (fit ↔ 100%) |
|
|
||||||
| `+` / `-` | Zoom in / out |
|
|
||||||
|
|
||||||
#### Flagging & Rating
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `P` | Pick (flag) |
|
|
||||||
| `X` | Reject |
|
|
||||||
| `U` | Unflag |
|
|
||||||
| `1–5` | Set star rating |
|
|
||||||
| `0` | Remove star rating |
|
|
||||||
| `6` | Red label |
|
|
||||||
| `7` | Orange label |
|
|
||||||
| `8` | Yellow label |
|
|
||||||
| `9` | Green label |
|
|
||||||
|
|
||||||
#### Actions
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `G` | Go to grid view |
|
|
||||||
| `E` | Go to loupe view |
|
|
||||||
| `C` | Compare view (2 selected) |
|
|
||||||
| `Tab` | Toggle left sidebar |
|
|
||||||
| `I` | Toggle right metadata sidebar |
|
|
||||||
| `\` | Toggle filter bar |
|
|
||||||
| `F` | Toggle fullscreen |
|
|
||||||
| `Delete` | Move selected to trash |
|
|
||||||
| `Shift+Delete` | Permanently delete (if in trash view) |
|
|
||||||
| `Cmd/Ctrl+Z` | Undo last action |
|
|
||||||
| `Cmd/Ctrl+Shift+Z` | Redo |
|
|
||||||
| `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) |
|
|
||||||
| `Cmd/Ctrl+X` | Cut selected (for move) |
|
|
||||||
| `Cmd/Ctrl+V` | Paste into current folder |
|
|
||||||
| `T` | Add/remove from active heap |
|
|
||||||
| `Cmd/Ctrl+F` | Focus search bar |
|
|
||||||
| `/` | Focus search bar |
|
|
||||||
| `?` | Show keyboard shortcut reference overlay |
|
|
||||||
|
|
||||||
All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea.
|
|
||||||
|
|
||||||
### 6.5 Bulk Selection
|
|
||||||
|
|
||||||
- **Click** — select single photo (deselects others)
|
|
||||||
- **Shift+Click** — range select from last selected to clicked
|
|
||||||
- **Cmd/Ctrl+Click** — toggle individual photo in selection
|
|
||||||
- **Cmd/Ctrl+A** — select all visible
|
|
||||||
- A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export
|
|
||||||
- Bulk actions call `POST /api/v1/photos/bulk`
|
|
||||||
|
|
||||||
### 6.6 Metadata Sidebar (Right Panel)
|
|
||||||
|
|
||||||
When a photo is selected, the right sidebar shows:
|
|
||||||
|
|
||||||
**Section: Preview**
|
|
||||||
- Large thumbnail (clicking opens loupe)
|
|
||||||
- Filename (editable inline, renames file on disk)
|
|
||||||
- User title (editable)
|
|
||||||
- User notes (textarea)
|
|
||||||
- Rating (5-star widget, keyboard-interactive)
|
|
||||||
- Color label (color dot picker)
|
|
||||||
- Flags: Picked / Rejected toggles
|
|
||||||
|
|
||||||
**Section: Tags**
|
|
||||||
- Tag chips with remove button
|
|
||||||
- "Add tag" autocomplete input
|
|
||||||
- Create new tag inline
|
|
||||||
|
|
||||||
**Section: EXIF / Metadata**
|
|
||||||
Collapsible groups:
|
|
||||||
- *Camera*: Make, Model, Serial, Lens, Firmware
|
|
||||||
- *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode
|
|
||||||
- *File*: Format, Dimensions, File Size, Color Space, Bit Depth
|
|
||||||
- *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand)
|
|
||||||
- *IPTC/XMP*: Copyright, Creator, Description, Keywords
|
|
||||||
|
|
||||||
**Section: Histogram** (stretch goal)
|
|
||||||
- Live RGB+Luminosity histogram rendered from a downsampled version of the photo
|
|
||||||
|
|
||||||
### 6.7 Filter Bar
|
|
||||||
|
|
||||||
A collapsible horizontal bar below the top bar (shortcut `\`). Contains:
|
|
||||||
|
|
||||||
| Control | Type |
|
|
||||||
|---------|------|
|
|
||||||
| Date range | Date range picker (from/to) |
|
|
||||||
| Media type | Multi-select chips: Photo / Video / RAW / HEIC |
|
|
||||||
| Rating | Min/max star slider |
|
|
||||||
| Color label | Color dot multi-select |
|
|
||||||
| Flags | Picked / Rejected / Unflagged toggle buttons |
|
|
||||||
| Tags | Multi-select tag dropdown (AND/OR mode toggle) |
|
|
||||||
| Camera make | Dropdown (populated from DB) |
|
|
||||||
| Lens | Dropdown (populated from DB) |
|
|
||||||
|
|
||||||
Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks.
|
|
||||||
|
|
||||||
### 6.8 Search
|
|
||||||
|
|
||||||
- Magnifier icon in top bar, shortcut `/` or `Cmd+F`
|
|
||||||
- Full-text search via FTS5 backend
|
|
||||||
- Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags
|
|
||||||
- Results appear inline in the current view (no separate search results page)
|
|
||||||
- Search combined with active filters (additive)
|
|
||||||
|
|
||||||
### 6.9 Folder Tree (Left Sidebar)
|
|
||||||
|
|
||||||
- Hierarchical tree view of all source roots and their subfolder structure
|
|
||||||
- Each folder shows photo count badge
|
|
||||||
- Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path
|
|
||||||
- Drag-and-drop folders to rearrange (moves directory on disk with confirmation)
|
|
||||||
- "All Photos" virtual root node at top
|
|
||||||
- "Trash" virtual node at bottom with count badge
|
|
||||||
|
|
||||||
### 6.10 Heaps Panel (Left Sidebar)
|
|
||||||
|
|
||||||
- List of named heaps below folder tree
|
|
||||||
- "+ New Heap" button (creates unnamed heap, prompts for name)
|
|
||||||
- Each heap shows photo count
|
|
||||||
- Click heap → main area shows heap contents in grid
|
|
||||||
- Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap
|
|
||||||
- **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap.
|
|
||||||
- A persistent "current heap" pill shown in the top bar when a heap is active
|
|
||||||
|
|
||||||
### 6.11 Loupe View
|
|
||||||
|
|
||||||
- Single photo, full-viewport
|
|
||||||
- Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display)
|
|
||||||
- Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+
|
|
||||||
- Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable
|
|
||||||
- Left panel collapse, right metadata panel still accessible
|
|
||||||
- For videos: HTML5 `<video>` player with controls, muted autoplay of preview, unmute toggle
|
|
||||||
|
|
||||||
### 6.12 Trash View
|
|
||||||
|
|
||||||
- Accessible via "Trash" node in sidebar
|
|
||||||
- Same grid layout, same filters, same shortcuts
|
|
||||||
- Extra actions in bulk selection bar: Restore, Permanently Delete
|
|
||||||
- "Empty Trash" button at top with confirmation dialog showing count + total size
|
|
||||||
|
|
||||||
### 6.13 Library Scan Progress
|
|
||||||
|
|
||||||
- On first launch or manual scan trigger: a non-blocking progress bar in the top bar
|
|
||||||
- Shows: `Scanning… 1,234 / 12,456 photos indexed`
|
|
||||||
- Photos appear in the timeline as they are indexed (optimistic streaming via polling `GET /library/scan/status` every 2s)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Media Handling
|
|
||||||
|
|
||||||
### 7.1 Supported Formats
|
|
||||||
|
|
||||||
| Category | Formats |
|
|
||||||
|----------|---------|
|
|
||||||
| JPEG | `.jpg`, `.jpeg` |
|
|
||||||
| PNG | `.png` |
|
|
||||||
| TIFF | `.tif`, `.tiff` |
|
|
||||||
| WebP | `.webp` |
|
|
||||||
| HEIC/HEIF | `.heic`, `.heif` (via pillow-heif) |
|
|
||||||
| RAW — Canon | `.cr2`, `.cr3` |
|
|
||||||
| RAW — Nikon | `.nef`, `.nrw` |
|
|
||||||
| RAW — Sony | `.arw`, `.srf` |
|
|
||||||
| RAW — Fuji | `.raf` |
|
|
||||||
| RAW — Panasonic | `.rw2` |
|
|
||||||
| RAW — Olympus | `.orf` |
|
|
||||||
| RAW — Samsung | `.srw` |
|
|
||||||
| RAW — Pentax | `.pef` |
|
|
||||||
| RAW — Leica | `.rwl`, `.dng` |
|
|
||||||
| RAW — DNG (universal) | `.dng` |
|
|
||||||
| RAW — Others | via rawpy (libraw) fallback |
|
|
||||||
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.mts`, `.m2ts`, `.3gp` |
|
|
||||||
| Live Photos | `.heic` + `.mov` pair (detect by matching base filename) |
|
|
||||||
|
|
||||||
### 7.2 Thumbnail Generation Pipeline
|
|
||||||
|
|
||||||
For each photo during indexing:
|
|
||||||
1. Detect format by extension + magic bytes
|
|
||||||
2. Decode to in-memory RGB image:
|
|
||||||
- JPEG/PNG/TIFF/WebP → pyvips native
|
|
||||||
- HEIC/HEIF → pillow-heif → pyvips
|
|
||||||
- RAW → rawpy (half-size decode for speed) → numpy → pyvips
|
|
||||||
- Video → ffmpeg extract frame at 10% duration → pyvips
|
|
||||||
3. Auto-rotate by EXIF orientation
|
|
||||||
4. Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
|
|
||||||
5. Save as WebP (quality 85) to `/data/thumbs/{photo_id}/{size}.webp`
|
|
||||||
6. Update `thumb_small`, `thumb_medium`, `thumb_large` columns in DB
|
|
||||||
|
|
||||||
For web display of original RAW/HEIC in loupe view: generate a full-res WebP proxy on demand (cached). Serve via `GET /photos/{id}/proxy`.
|
|
||||||
|
|
||||||
### 7.3 Metadata Extraction
|
|
||||||
|
|
||||||
Run ExifTool (subprocess) on every file during indexing. Parse output JSON. Store:
|
|
||||||
- `taken_at` — prefer `DateTimeOriginal`, fallback: `CreateDate`, `MediaCreateDate`, file mtime
|
|
||||||
- GPS coordinates if present
|
|
||||||
- All EXIF/IPTC/XMP fields stored as JSON in `exif_json`
|
|
||||||
- Denormalize key fields to FTS table for search
|
|
||||||
|
|
||||||
For Live Photos: link the `.mov` sidecar to the `.heic` via a `live_photo_video_id` FK on the photos table.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. File Operations
|
|
||||||
|
|
||||||
All file operations that touch disk must:
|
|
||||||
1. Validate target path is within a known source_root (prevent path traversal)
|
|
||||||
2. Execute atomically where possible (temp file + rename)
|
|
||||||
3. Update DB after successful disk operation (never before)
|
|
||||||
4. Emit a WebSocket event (or SSE) so the frontend can update optimistically
|
|
||||||
5. Be undoable via Undo stack (store reverse operation in memory, max 50 ops)
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
|
|
||||||
| Operation | Disk action | DB action |
|
|
||||||
|-----------|-------------|-----------|
|
|
||||||
| Move photos | `shutil.move` | Update `filepath`, `folder_id` |
|
|
||||||
| Copy photos | `shutil.copy2` | Insert new photo record |
|
|
||||||
| Rename file | `os.rename` | Update `filepath`, `filename` |
|
|
||||||
| Rename folder | `os.rename` | Update folder `path` recursively |
|
|
||||||
| Create folder | `os.makedirs` | Insert folder record |
|
|
||||||
| Trash photo | Move to `/data/trash/{id}/original.{ext}` | Set `is_trashed=1`, `trashed_at` |
|
|
||||||
| Restore from trash | Move back to original path (or new path if original gone) | Clear `is_trashed` |
|
|
||||||
| Permanent delete | `os.unlink` | Delete photo record (cascade to tags, heaps) |
|
|
||||||
| Convert heap to folder | `os.makedirs(target)` + move/copy each photo | Insert folder, update photo folder_id |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Performance Requirements
|
|
||||||
|
|
||||||
- **Initial page load**: < 2s (LCP)
|
|
||||||
- **Timeline scroll** (10,000+ photos): 60 fps — enforced by TanStack Virtual (only ~20-30 DOM nodes rendered at any time)
|
|
||||||
- **Thumbnail serve**: < 50ms via Nginx X-Accel-Redirect (backend sets header, Nginx serves file directly)
|
|
||||||
- **Search**: < 200ms for FTS5 query on 100k photos
|
|
||||||
- **Thumbnail generation**: ≥ 10 photos/sec on typical homelab CPU (pyvips is ~10x faster than Pillow)
|
|
||||||
- **Scan throughput**: ≥ 500 files/sec metadata scan (ExifTool batch mode processes files in bulk)
|
|
||||||
- **Celery workers**: 4 concurrent workers by default (`CELERYD_CONCURRENCY=4` env var)
|
|
||||||
- Images not yet thumbnailed show a shimmer skeleton; thumbnails stream into view as they complete
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. UI Design System
|
|
||||||
|
|
||||||
### 10.1 Aesthetic
|
|
||||||
|
|
||||||
Dark-first application (photography tools are dark-themed to preserve color perception). Light mode available via toggle.
|
|
||||||
|
|
||||||
- **Dark mode primary surface**: Near-black warm dark `#111110`, not cold gray
|
|
||||||
- **Accent**: Desaturated teal — does not compete with photo colors
|
|
||||||
- **Typography**: `Geist` (body, UI chrome) + `Geist Mono` (metadata values, EXIF numbers)
|
|
||||||
- Dense UI — this is a power tool, not a consumer app. Compact spacing.
|
|
||||||
- Inspired by: Lightroom Classic, Linear, Darkroom (iOS)
|
|
||||||
|
|
||||||
### 10.2 Key UI Components (shadcn/ui)
|
|
||||||
|
|
||||||
Use these shadcn/ui primitives: `Button`, `ContextMenu`, `Dialog`, `DropdownMenu`, `Input`, `Label`, `Popover`, `ScrollArea`, `Separator`, `Sheet` (for mobile sidebar), `Skeleton`, `Slider`, `Switch`, `Tabs`, `Textarea`, `Toast`, `Tooltip`
|
|
||||||
|
|
||||||
Build custom components:
|
|
||||||
- `<PhotoThumbnail>` — thumbnail with selection state, pick/reject badges, rating overlay on hover
|
|
||||||
- `<TimelineGroup>` — sticky date header + grid of thumbnails
|
|
||||||
- `<VirtualTimeline>` — TanStack Virtual wrapper over TimelineGroups
|
|
||||||
- `<FilmStrip>` — horizontal scrollable strip for loupe view
|
|
||||||
- `<StarRating>` — interactive 0-5 stars
|
|
||||||
- `<ColorLabel>` — 7-state color dot picker
|
|
||||||
- `<MetadataRow>` — label + value pair with edit-in-place for editable fields
|
|
||||||
- `<FolderTreeNode>` — recursive folder tree item with context menu
|
|
||||||
- `<HeapItem>` — heap list item with active indicator
|
|
||||||
- `<FilterChip>` — removable active filter chip
|
|
||||||
- `<ProgressBar>` — scan progress in top bar
|
|
||||||
- `<ShortcutReference>` — `?` overlay showing all shortcuts in a modal
|
|
||||||
|
|
||||||
### 10.3 Color Scheme Variables
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Dark mode (default for photo apps) */
|
|
||||||
--color-bg: #111110;
|
|
||||||
--color-surface: #161615;
|
|
||||||
--color-surface-2: #1c1c1a;
|
|
||||||
--color-surface-offset: #222220;
|
|
||||||
--color-border: rgba(255,255,255,0.08);
|
|
||||||
--color-text: #e8e6e0;
|
|
||||||
--color-text-muted: #878580;
|
|
||||||
--color-text-faint: #4a4845;
|
|
||||||
--color-primary: #4f98a3; /* desaturated teal */
|
|
||||||
--color-pick: #4f9e5c; /* green for picked */
|
|
||||||
--color-reject: #c25a5a; /* red for rejected */
|
|
||||||
--color-star: #d4a340; /* amber for stars */
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Error States & Edge Cases
|
|
||||||
|
|
||||||
- **File not found on disk** (moved externally): Show "missing file" badge on thumbnail. Offer "Locate File" action.
|
|
||||||
- **Corrupt/unreadable file**: Log error, show broken-image placeholder, never crash the scan worker.
|
|
||||||
- **Duplicate detection**: On scan, if a file with the same SHA-256 hash already exists in DB, mark as `is_duplicate=true` — do not create a second record. Show duplicate indicator in thumbnail.
|
|
||||||
- **Scan in progress + user navigates**: Show partial results immediately as photos are indexed.
|
|
||||||
- **Disk full**: Catch `OSError` on thumbnail write, log, continue scan.
|
|
||||||
- **RAW decode failure**: Fall back to extracting the embedded JPEG preview from the RAW file (ExifTool can extract it).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Stretch Goals (Phase 2 — Not in Initial Build)
|
|
||||||
|
|
||||||
These must not be built now but the architecture must not block them:
|
|
||||||
|
|
||||||
1. **AI Scene Classification** — CLIP embeddings per photo, semantic search ("find photos with mountains")
|
|
||||||
2. **Face Detection & Clustering** — face_recognition lib or InsightFace, cluster by identity
|
|
||||||
3. **Smart Albums** — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
|
|
||||||
4. **Duplicate Finder** — perceptual hash (pHash) across library
|
|
||||||
5. **Export Presets** — resize + watermark + format conversion on export
|
|
||||||
6. **Multi-user** — add FastAPI auth (JWT), per-user libraries
|
|
||||||
7. **Mobile PWA** — service worker, offline thumbnail caching
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Docker Compose File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
photovault/
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── photovault.yml ← user config
|
|
||||||
├── .env ← PHOTO_DIRS, REDIS_URL, etc.
|
|
||||||
├── frontend/
|
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── package.json
|
|
||||||
│ ├── vite.config.ts
|
|
||||||
│ └── src/
|
|
||||||
│ ├── main.tsx
|
|
||||||
│ ├── App.tsx
|
|
||||||
│ ├── store/ ← Zustand stores
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── layout/
|
|
||||||
│ │ ├── timeline/
|
|
||||||
│ │ ├── loupe/
|
|
||||||
│ │ ├── sidebar/
|
|
||||||
│ │ ├── metadata/
|
|
||||||
│ │ └── shared/
|
|
||||||
│ ├── hooks/
|
|
||||||
│ ├── api/ ← TanStack Query hooks + axios client
|
|
||||||
│ └── lib/
|
|
||||||
│ └── shortcuts.ts ← global keyboard shortcut registry
|
|
||||||
└── backend/
|
|
||||||
├── Dockerfile
|
|
||||||
├── requirements.txt
|
|
||||||
├── alembic/
|
|
||||||
├── app/
|
|
||||||
│ ├── main.py ← FastAPI app
|
|
||||||
│ ├── config.py ← pydantic settings
|
|
||||||
│ ├── database.py ← SQLAlchemy async engine
|
|
||||||
│ ├── models/ ← SQLAlchemy ORM models
|
|
||||||
│ ├── schemas/ ← Pydantic request/response schemas
|
|
||||||
│ ├── routers/ ← FastAPI routers per domain
|
|
||||||
│ │ ├── photos.py
|
|
||||||
│ │ ├── folders.py
|
|
||||||
│ │ ├── heaps.py
|
|
||||||
│ │ ├── tags.py
|
|
||||||
│ │ ├── trash.py
|
|
||||||
│ │ └── library.py
|
|
||||||
│ ├── services/ ← Business logic
|
|
||||||
│ │ ├── scanner.py
|
|
||||||
│ │ ├── thumbnailer.py
|
|
||||||
│ │ ├── metadata.py
|
|
||||||
│ │ └── file_ops.py
|
|
||||||
│ └── tasks/ ← Celery tasks
|
|
||||||
│ ├── celery.py
|
|
||||||
│ ├── scan.py
|
|
||||||
│ └── thumbs.py
|
|
||||||
└── nginx.conf ← X-Accel-Redirect for thumb serving
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Implementation Priorities
|
|
||||||
|
|
||||||
Build in this order to get a working MVP as fast as possible:
|
|
||||||
|
|
||||||
1. **Docker Compose skeleton** — all services up, health checks passing
|
|
||||||
2. **DB schema + Alembic migration**
|
|
||||||
3. **Folder scanner + thumbnail generator** (Celery tasks) — the core engine
|
|
||||||
4. **`GET /photos` + `GET /photos/{id}/thumb/{size}`** — minimum API to display photos
|
|
||||||
5. **Frontend: VirtualTimeline + PhotoThumbnail** — display the library
|
|
||||||
6. **Frontend: keyboard navigation + selection**
|
|
||||||
7. **Frontend: left sidebar (folder tree + heaps)**
|
|
||||||
8. **Frontend: right sidebar (metadata panel) + EXIF display**
|
|
||||||
9. **Filter bar + search**
|
|
||||||
10. **Loupe view with filmstrip**
|
|
||||||
11. **File operations: move, copy, rename, trash, restore**
|
|
||||||
12. **Metadata editing: title, notes, rating, color label, tags**
|
|
||||||
13. **Heaps: create, populate, convert to folder**
|
|
||||||
14. **Trash view + permanent delete**
|
|
||||||
15. **Polish: undo/redo, bulk actions, duplicate detection, live scan progress**
|
|
||||||
13
sidecar/.dockerignore
Normal file
13
sidecar/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Files that have no business shipping into the build context.
|
||||||
|
# Anything not listed here gets COPY'd into /src so keep this tight.
|
||||||
|
|
||||||
|
# Local host-mode build output — re-built inside the image.
|
||||||
|
mule-sidecar
|
||||||
|
|
||||||
|
# Runtime state from the M3 Node prototype.
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Docs + git noise.
|
||||||
|
README.md
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
28
sidecar/Dockerfile
Normal file
28
sidecar/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
#
|
||||||
|
# mule-sidecar — Go service for endpoints PhotoPrism does not expose.
|
||||||
|
# Multi-stage build: a Go toolchain image compiles a static binary,
|
||||||
|
# then we copy it onto a distroless base so the runtime image is ~12 MB
|
||||||
|
# with no shell, package manager, or libc.
|
||||||
|
|
||||||
|
FROM docker.io/library/golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache deps separately from source so a one-line code change doesn't
|
||||||
|
# re-download the whole module graph.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
# CGO disabled → fully static binary that runs on the distroless base
|
||||||
|
# (no libc resolution at startup). -trimpath strips local paths from
|
||||||
|
# debug info; -s -w drop the symbol table to keep the binary small.
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags='-s -w' \
|
||||||
|
-o /out/mule-sidecar .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:latest
|
||||||
|
COPY --from=build /out/mule-sidecar /mule-sidecar
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["/mule-sidecar"]
|
||||||
116
sidecar/README.md
Normal file
116
sidecar/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# mule-sidecar
|
||||||
|
|
||||||
|
Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not
|
||||||
|
expose. Same wire contract as the M3 Node prototype it replaces; the
|
||||||
|
SvelteKit client at [web/](../web/) talks to it transparently through
|
||||||
|
Vite's `/api/sidecar/*` proxy.
|
||||||
|
|
||||||
|
## What it owns
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ------ | --------------------------------------- | --------------------------------------------- |
|
||||||
|
| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. |
|
||||||
|
| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. |
|
||||||
|
| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). |
|
||||||
|
| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. |
|
||||||
|
| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. |
|
||||||
|
| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. |
|
||||||
|
| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. |
|
||||||
|
| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). |
|
||||||
|
| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. |
|
||||||
|
| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. |
|
||||||
|
| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. |
|
||||||
|
| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates/<ts>/`. |
|
||||||
|
|
||||||
|
Auth: every endpoint except `healthz` requires the caller's
|
||||||
|
`X-Auth-Token` header. The sidecar holds no service credentials — it
|
||||||
|
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
|
||||||
|
to confirm the session is live before doing anything destructive.
|
||||||
|
|
||||||
|
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
|
||||||
|
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
|
||||||
|
PhotoPrism reindex of the affected parent in the background.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
The sidecar is a `sidecar` service in the PhotoPrism compose stack.
|
||||||
|
Bringing the whole stack up brings it up too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
podman-compose --env-file .env \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f docker-compose.podman.yml \
|
||||||
|
up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds [Dockerfile](Dockerfile) (multi-stage `golang:1.25-alpine` →
|
||||||
|
`gcr.io/distroless/static`, ~12 MB final image), starts the container,
|
||||||
|
and binds `127.0.0.1:8000` to the service. The SvelteKit dev server
|
||||||
|
proxies `/api/sidecar/*` to that port transparently.
|
||||||
|
|
||||||
|
### Dev-iteration loop (host build)
|
||||||
|
|
||||||
|
For tight iteration without rebuilding the image on every change you
|
||||||
|
can run it as a host process — Go is already on the dev machine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sidecar
|
||||||
|
go build -o mule-sidecar .
|
||||||
|
|
||||||
|
ORIGINALS_ROOT=/path/to/photoprism/originals \
|
||||||
|
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||||
|
SIDECAR_PORT=8000 \
|
||||||
|
./mule-sidecar
|
||||||
|
```
|
||||||
|
|
||||||
|
The host build connects to `mariadb` via the loopback port the compose
|
||||||
|
file publishes; stop `pp-sidecar` first so they don't fight for 8000.
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| Var | Default | Notes |
|
||||||
|
| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. |
|
||||||
|
| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. |
|
||||||
|
| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. |
|
||||||
|
| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. |
|
||||||
|
| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. |
|
||||||
|
| `SIDECAR_DB_PORT` | `3306` | |
|
||||||
|
| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. |
|
||||||
|
| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. |
|
||||||
|
| `SIDECAR_DB_NAME` | `mule_sidecar` | |
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
GORM `AutoMigrate` creates the only table the service owns:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE marks (
|
||||||
|
photo_uid VARCHAR(64) PRIMARY KEY,
|
||||||
|
rating BIGINT NULL,
|
||||||
|
color VARCHAR(16) NULL,
|
||||||
|
updated_at DATETIME(3)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The M3 Node prototype kept the same data in `sidecar/data/marks.json`.
|
||||||
|
There is no migration path — the prototype's marks file was dev-only
|
||||||
|
state. Heap-sharing tables (M4) will land in subsequent migrations.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
sidecar/
|
||||||
|
├── Dockerfile multi-stage golang:1.25 → distroless/static
|
||||||
|
├── main.go entrypoint, route wiring, graceful shutdown
|
||||||
|
├── config.go env-driven Config
|
||||||
|
├── db.go GORM open + Mark model + AutoMigrate
|
||||||
|
├── auth.go requireSession middleware + ctxToken
|
||||||
|
├── fs.go path safety, walk, sha1
|
||||||
|
├── pp.go PhotoPrism HTTP client (validateSession, reindex)
|
||||||
|
├── handlers_rename.go
|
||||||
|
├── handlers_folders.go
|
||||||
|
├── handlers_marks.go
|
||||||
|
├── handlers_heap.go
|
||||||
|
└── handlers_dups.go
|
||||||
|
```
|
||||||
44
sidecar/auth.go
Normal file
44
sidecar/auth.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireSession is the standard auth shim every mutating handler wears.
|
||||||
|
// We don't store a shared service credential — the caller's X-Auth-Token
|
||||||
|
// is the only authority, and we probe PhotoPrism with it before doing any
|
||||||
|
// destructive work. The handler reads the validated token off the context
|
||||||
|
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
||||||
|
// operation.
|
||||||
|
func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := c.GetHeader("X-Auth-Token")
|
||||||
|
if token == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !pp.validateSession(c.Request.Context(), token) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("token", token)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctxToken returns the validated X-Auth-Token a previous requireSession
|
||||||
|
// middleware stored on the request. Handlers MUST run behind that
|
||||||
|
// middleware; otherwise this returns the empty string.
|
||||||
|
func ctxToken(c *gin.Context) string {
|
||||||
|
v, ok := c.Get("token")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
92
sidecar/config.go
Normal file
92
sidecar/config.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config aggregates every runtime knob the sidecar reads from the
|
||||||
|
// environment. Held in one struct so the rest of the package can take a
|
||||||
|
// pointer instead of poking os.Getenv at use-sites.
|
||||||
|
type Config struct {
|
||||||
|
OriginalsRoot string // absolute path to PhotoPrism's originals dir
|
||||||
|
PhotoprismBaseURL string // e.g. http://localhost:2342
|
||||||
|
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
|
||||||
|
Port int // HTTP listen port
|
||||||
|
DSN string // GORM/MySQL connection string for mule_sidecar
|
||||||
|
// PpDSN is a second DB connection string pointed at PhotoPrism's own
|
||||||
|
// schema (`photoprism.*`). Sidecar code that needs to mutate
|
||||||
|
// PhotoPrism-managed rows (e.g. auth_users.base_path) opens its own
|
||||||
|
// connection with these creds rather than asking for grants on the
|
||||||
|
// mule_sidecar user. Empty if PP_DB_PASSWORD isn't provided, in
|
||||||
|
// which case PP-touching features (user-basepath reconciler) stay
|
||||||
|
// dormant.
|
||||||
|
PpDSN string
|
||||||
|
// UserBasepaths is the parsed `USER_BASEPATHS` env. Maps PhotoPrism
|
||||||
|
// usernames to originals-relative base paths so OIDC-provisioned
|
||||||
|
// users land with the right library scope without any admin
|
||||||
|
// touching `photoprism users mod`.
|
||||||
|
UserBasepaths map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (*Config, error) {
|
||||||
|
root := envOr("ORIGINALS_ROOT", "/photoprism/originals")
|
||||||
|
abs, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
portStr := envOr("SIDECAR_PORT", "8000")
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := os.Getenv("SIDECAR_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
// Default matches the user that mariadb/init/01-sidecar.sql provisions
|
||||||
|
// on first boot. The literal placeholder password is intentional: the
|
||||||
|
// SQL ships with it and an env-templating step is left for whoever
|
||||||
|
// runs this in a non-local context.
|
||||||
|
user := envOr("SIDECAR_DB_USER", "sidecar")
|
||||||
|
pass := envOr("SIDECAR_DB_PASSWORD", "replace-at-m4-bringup")
|
||||||
|
host := envOr("SIDECAR_DB_HOST", "127.0.0.1")
|
||||||
|
dbPort := envOr("SIDECAR_DB_PORT", "3306")
|
||||||
|
name := envOr("SIDECAR_DB_NAME", "mule_sidecar")
|
||||||
|
dsn = user + ":" + pass + "@tcp(" + host + ":" + dbPort + ")/" + name +
|
||||||
|
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhotoPrism schema connection — only used by the user-basepath
|
||||||
|
// reconciler. Stays empty if PP_DB_PASSWORD isn't set, and callers
|
||||||
|
// gate behaviour on that. We use PhotoPrism's own DB user rather
|
||||||
|
// than the sidecar's because `mule_sidecar` has no grants on
|
||||||
|
// `photoprism.*` (see mariadb/init/01-sidecar.sql).
|
||||||
|
ppDSN := ""
|
||||||
|
if ppPass := os.Getenv("PP_DB_PASSWORD"); ppPass != "" {
|
||||||
|
ppUser := envOr("PP_DB_USER", "photoprism")
|
||||||
|
ppHost := envOr("PP_DB_HOST", envOr("SIDECAR_DB_HOST", "mariadb"))
|
||||||
|
ppPort := envOr("PP_DB_PORT", envOr("SIDECAR_DB_PORT", "3306"))
|
||||||
|
ppName := envOr("PP_DB_NAME", "photoprism")
|
||||||
|
ppDSN = ppUser + ":" + ppPass + "@tcp(" + ppHost + ":" + ppPort + ")/" + ppName +
|
||||||
|
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
OriginalsRoot: abs,
|
||||||
|
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
|
||||||
|
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
|
||||||
|
Port: port,
|
||||||
|
DSN: dsn,
|
||||||
|
PpDSN: ppDSN,
|
||||||
|
UserBasepaths: parseUserBasepaths(os.Getenv("USER_BASEPATHS")),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
63
sidecar/db.go
Normal file
63
sidecar/db.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mark mirrors the per-photo extras the web client stores via the marks
|
||||||
|
// endpoints — rating + four-colour label. PhotoUID is the row key; both
|
||||||
|
// payload fields are nullable so the sparse "no rating / no colour" state
|
||||||
|
// round-trips cleanly. The Node prototype kept this in a JSON file; we
|
||||||
|
// migrate to MariaDB here so the M4 sharing work has a real table to
|
||||||
|
// extend.
|
||||||
|
type Mark struct {
|
||||||
|
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
||||||
|
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
|
||||||
|
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName pins the GORM-pluralised default to a name that matches the
|
||||||
|
// other tables the M4 plan calls out (`marks`, `heap_shares`, …) so
|
||||||
|
// nothing surprising lands in the schema.
|
||||||
|
func (Mark) TableName() string { return "marks" }
|
||||||
|
|
||||||
|
// asJSON returns the wire shape clients expect — same flat object the
|
||||||
|
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
|
||||||
|
// as `{}` which the client treats as "no mark on this photo".
|
||||||
|
func (m *Mark) asJSON() map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
if m == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if m.Rating != nil {
|
||||||
|
out["rating"] = *m.Rating
|
||||||
|
}
|
||||||
|
if m.Color != nil && *m.Color != "" {
|
||||||
|
out["color"] = *m.Color
|
||||||
|
}
|
||||||
|
if !m.UpdatedAt.IsZero() {
|
||||||
|
// ISO-8601 with millisecond precision, UTC — matches the Node
|
||||||
|
// prototype's `new Date().toISOString()` so clients written against
|
||||||
|
// the old endpoint stay happy.
|
||||||
|
out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(dsn string) (*gorm.DB, error) {
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Warn),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&Mark{}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
213
sidecar/fs.go
Normal file
213
sidecar/fs.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeFilename trims a user-supplied filename and rejects anything
|
||||||
|
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
|
||||||
|
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
|
||||||
|
// with most filename shapes; we lock down the ones a malicious or
|
||||||
|
// careless caller might use to escape the folder.
|
||||||
|
func sanitizeFilename(name string) (string, bool) {
|
||||||
|
s := strings.TrimSpace(name)
|
||||||
|
if s == "" || len(s) > 240 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, ".") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if s == "." || s == ".." {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(s, "/\\\x00") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveUnderRoot takes a user-supplied relative path and returns its
|
||||||
|
// absolute form, but only when the resolved location lives under the
|
||||||
|
// configured originals root. Symlink escapes are caught by resolving the
|
||||||
|
// parent through filepath.EvalSymlinks first.
|
||||||
|
//
|
||||||
|
// `mustExist=false` is for the *target* of a rename/create where the
|
||||||
|
// terminal path isn't on disk yet; the parent still has to exist and
|
||||||
|
// still has to be inside the root.
|
||||||
|
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
|
||||||
|
if rel == "" {
|
||||||
|
return "", errors.New("empty path")
|
||||||
|
}
|
||||||
|
clean := strings.TrimLeft(rel, "/")
|
||||||
|
if clean == "" || clean == "." {
|
||||||
|
return "", errors.New("empty path")
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(clean, "/") {
|
||||||
|
if seg == "" || seg == ".." {
|
||||||
|
return "", errors.New("path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abs := filepath.Join(root, clean)
|
||||||
|
parent := filepath.Dir(abs)
|
||||||
|
|
||||||
|
parentReal, err := filepath.EvalSymlinks(parent)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !sameOrUnder(parentReal, root) {
|
||||||
|
return "", errors.New("parent escapes originals root")
|
||||||
|
}
|
||||||
|
if mustExist {
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureWithinOriginals checks that an absolute path's parent resolves to
|
||||||
|
// somewhere inside the root after symlink evaluation. Used for the
|
||||||
|
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
|
||||||
|
func ensureWithinOriginals(root, absPath string) bool {
|
||||||
|
real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sameOrUnder(real, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameOrUnder(p, root string) bool {
|
||||||
|
if p == root {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(p, root+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueName resolves "destDir/basename" against collisions by appending
|
||||||
|
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
|
||||||
|
// can't pin the goroutine forever.
|
||||||
|
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
|
||||||
|
ext := filepath.Ext(basename)
|
||||||
|
stem := strings.TrimSuffix(basename, ext)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
candidate := basename
|
||||||
|
if i > 0 {
|
||||||
|
candidate = stem + "-" + itoa(i) + ext
|
||||||
|
}
|
||||||
|
p := filepath.Join(destDir, candidate)
|
||||||
|
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return p, candidate, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa is the tiny stdlib-free formatter we use inside hot loops.
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileEntry is the per-file row walkFiles emits. relPath stays root-
|
||||||
|
// relative so it can land in API responses unchanged.
|
||||||
|
type fileEntry struct {
|
||||||
|
RelPath string
|
||||||
|
AbsPath string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
|
||||||
|
// itself walks the same set; we keep the list in lock-step so callers
|
||||||
|
// don't see "duplicate" warnings about files PhotoPrism would ignore.
|
||||||
|
var supportedExts = map[string]struct{}{
|
||||||
|
".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
|
||||||
|
".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
|
||||||
|
".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
|
||||||
|
".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
|
||||||
|
".orf": {}, ".rw2": {}, ".raw": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkFiles enumerates every supported media file under root, skipping
|
||||||
|
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
|
||||||
|
// folder). Errors on individual entries are swallowed so a single
|
||||||
|
// permission-denied dir doesn't abort the whole scan.
|
||||||
|
func walkFiles(root string) ([]fileEntry, error) {
|
||||||
|
var out []fileEntry
|
||||||
|
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
// Permission errors etc. — skip the offending subtree but
|
||||||
|
// keep walking. The dup-scan endpoint is best-effort.
|
||||||
|
if d != nil && d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if p != root && strings.HasPrefix(name, ".") {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
if _, ok := supportedExts[ext]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, fileEntry{
|
||||||
|
RelPath: rel,
|
||||||
|
AbsPath: p,
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
|
||||||
|
// doesn't blow the process's RAM. Returns the hex digest.
|
||||||
|
func sha1File(absPath string) (string, error) {
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha1.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
45
sidecar/go.mod
Normal file
45
sidecar/go.mod
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
module mule-sidecar
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
101
sidecar/go.sum
Normal file
101
sidecar/go.sum
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
230
sidecar/handlers_dups.go
Normal file
230
sidecar/handlers_dups.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const quarantineDir = ".duplicates"
|
||||||
|
|
||||||
|
type dupFileLite struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupGroup struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IndexedPath *string `json:"indexedPath"`
|
||||||
|
Files []dupFileLite `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||||||
|
// looking up "which file path has this hash already indexed", used to
|
||||||
|
// hint the UI which copy to keep.
|
||||||
|
type dupListPhoto struct {
|
||||||
|
Files []ppFile `json:"Files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
start := time.Now()
|
||||||
|
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
|
||||||
|
|
||||||
|
all, err := walkFiles(cfg.OriginalsRoot)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by size first: byte-identical files necessarily share size,
|
||||||
|
// so size-collision is a cheap O(N) prefilter that lets us skip
|
||||||
|
// hashing >95% of a typical library.
|
||||||
|
bySize := map[int64][]fileEntry{}
|
||||||
|
for _, f := range all {
|
||||||
|
bySize[f.Size] = append(bySize[f.Size], f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS
|
||||||
|
// so we don't drown the disk with parallel reads on a spinning HDD.
|
||||||
|
type hashOut struct {
|
||||||
|
hash string
|
||||||
|
f fileEntry
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
sem = make(chan struct{}, 4)
|
||||||
|
outMu sync.Mutex
|
||||||
|
byHash = map[string][]fileEntry{}
|
||||||
|
hashSize = map[string]int64{}
|
||||||
|
)
|
||||||
|
for size, group := range bySize {
|
||||||
|
if len(group) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range group {
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(f fileEntry, sz int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
h, err := sha1File(f.AbsPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outMu.Lock()
|
||||||
|
byHash[h] = append(byHash[h], f)
|
||||||
|
hashSize[h] = sz
|
||||||
|
outMu.Unlock()
|
||||||
|
}(f, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Drop singletons (size collision but different hashes), then ask
|
||||||
|
// PhotoPrism which of the duplicates it has indexed so the UI can
|
||||||
|
// default the "keep" selection to that one.
|
||||||
|
groups := make([]dupGroup, 0)
|
||||||
|
for h, files := range byHash {
|
||||||
|
if len(files) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g := dupGroup{Hash: h, Size: hashSize[h]}
|
||||||
|
for _, f := range files {
|
||||||
|
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
|
||||||
|
}
|
||||||
|
// Best-effort lookup; swallow errors. The hash query is cheap on
|
||||||
|
// PhotoPrism's side (indexed column).
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||||||
|
"/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil)
|
||||||
|
if err == nil && resp.OK {
|
||||||
|
var photos []dupListPhoto
|
||||||
|
if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 {
|
||||||
|
if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" {
|
||||||
|
p := pf.Name
|
||||||
|
g.IndexedPath = &p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
// Sort by reclaimable bytes descending (size × duplicate-count) so
|
||||||
|
// the biggest wins float to the top of the UI.
|
||||||
|
sort.Slice(groups, func(i, j int) bool {
|
||||||
|
return groups[i].Size*int64(len(groups[i].Files)-1) >
|
||||||
|
groups[j].Size*int64(len(groups[j].Files)-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
ms := time.Since(start).Milliseconds()
|
||||||
|
slog.Info("dup.scan done", "groups", len(groups), "ms", ms)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"groups": groups,
|
||||||
|
"scannedMs": ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupArchiveBody struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupMoved struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupArchiveErr struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
var body dupArchiveBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each archive batch lands in its own timestamped subdir so the
|
||||||
|
// user can browse what was quarantined when (and recover by hand
|
||||||
|
// if they change their mind).
|
||||||
|
stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
|
||||||
|
targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp)
|
||||||
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveOne moves a single file into the quarantine batch dir
|
||||||
|
// and returns the new relative path. Disambiguates same-basename
|
||||||
|
// collisions within the batch so two `IMG_0001.jpg` from
|
||||||
|
// different folders don't clobber each other.
|
||||||
|
archiveOne := func(rel string) (string, error) {
|
||||||
|
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("invalid path")
|
||||||
|
}
|
||||||
|
base := filepath.Base(abs)
|
||||||
|
dest := filepath.Join(targetDir, base)
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
stem := base[:len(base)-len(filepath.Ext(base))]
|
||||||
|
dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base))
|
||||||
|
}
|
||||||
|
if err := os.Rename(abs, dest); err != nil {
|
||||||
|
// EXDEV fallback — copy+remove for libraries that span
|
||||||
|
// filesystems (e.g. originals on a different mount).
|
||||||
|
if err2 := copyFile(abs, dest); err2 != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err2 := os.Remove(abs); err2 != nil {
|
||||||
|
return "", errors.New("moved but source remove failed: " + err2.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest)
|
||||||
|
return relDest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
moved := []dupMoved{}
|
||||||
|
errs := []dupArchiveErr{}
|
||||||
|
for _, rel := range body.Paths {
|
||||||
|
relDest, err := archiveOne(rel)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moved = append(moved, dupMoved{From: rel, To: relDest})
|
||||||
|
slog.Info("dup.archive", "from", rel, "to", relDest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex the entire library so PhotoPrism drops rows for the
|
||||||
|
// archived files. cleanup:true is critical — the files still
|
||||||
|
// exist on disk, just under .duplicates/ which the indexer
|
||||||
|
// ignores.
|
||||||
|
if len(moved) > 0 {
|
||||||
|
go func() {
|
||||||
|
if err := pp.reindex(context.Background(), token, "/"); err != nil {
|
||||||
|
slog.Warn("dup.archive reindex failed", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
|
||||||
|
}
|
||||||
|
}
|
||||||
307
sidecar/handlers_folders.go
Normal file
307
sidecar/handlers_folders.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pathParam pulls the URL-encoded :rel out of the Gin context and
|
||||||
|
// unescapes it. UseRawPath is on at the router level (see main.go) so the
|
||||||
|
// raw value still carries `%2F` for nested paths; we decode here.
|
||||||
|
func pathParam(c *gin.Context, key string) (string, bool) {
|
||||||
|
raw := c.Param(key)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
dec, err := url.PathUnescape(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return dec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type folderCreateBody struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
var body folderCreateBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err == nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(abs, 0o755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
|
||||||
|
slog.Info("folder.create", "path", rel)
|
||||||
|
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type folderRenameBody struct {
|
||||||
|
NewName string `json:"newName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
rel, ok := pathParam(c, "rel")
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body folderRenameBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newName, ok := sanitizeFilename(body.NewName)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(oldAbs)
|
||||||
|
if err != nil || !st.IsDir() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
|
||||||
|
if _, err := os.Stat(newAbs); err == nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
|
||||||
|
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
|
||||||
|
slog.Info("folder.rename", "from", oldRel, "to", newRel)
|
||||||
|
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"oldPath": oldRel,
|
||||||
|
"newPath": newRel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
rel, ok := pathParam(c, "rel")
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abs == cfg.OriginalsRoot {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(abs)
|
||||||
|
if err != nil || !st.IsDir() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(abs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Remove(abs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("folder.delete", "path", rel)
|
||||||
|
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type folderCountsBody struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderCountsRow is the minimal PhotoPrism photo projection the handler
|
||||||
|
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
|
||||||
|
// expands one photo into one row per File on disk). PhotoPrism returns a
|
||||||
|
// JSON array of much richer objects; unmarshalling into this small
|
||||||
|
// shape ignores everything we don't care about.
|
||||||
|
type folderCountsRow struct {
|
||||||
|
UID string `json:"UID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFolderCounts returns photo counts for each PhotoPrism folder
|
||||||
|
// path in one round-trip. The web client used to fire one
|
||||||
|
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
|
||||||
|
// folder × N folders) to populate the left-sidebar tree. Moving the
|
||||||
|
// fan-out into the sidecar keeps the same correctness profile — same
|
||||||
|
// q-DSL, same `merged=false` UID dedupe — but the wire payload back
|
||||||
|
// to the browser collapses to a single small JSON object
|
||||||
|
// (`{path: count}`).
|
||||||
|
//
|
||||||
|
// We bounce off PhotoPrism with paginated `count=1000` calls and dedupe
|
||||||
|
// UIDs server-side rather than trusting a count header: PhotoPrism's
|
||||||
|
// `/photos` X-Count is the *per-page* row count (per existing front-end
|
||||||
|
// comment), not the total-match count, so we'd silently undercount any
|
||||||
|
// folder with more than 1000 files. The loop walks offsets until PP
|
||||||
|
// returns a short page, so the result is correct regardless of folder
|
||||||
|
// size (until the wider fan-out becomes the bottleneck, which is many
|
||||||
|
// orders of magnitude away on this hardware).
|
||||||
|
//
|
||||||
|
// Bounded concurrency caps the fan-out so a library with hundreds of
|
||||||
|
// folders doesn't open hundreds of connections to PhotoPrism at once.
|
||||||
|
// Errors per-folder degrade to count=0 rather than failing the whole
|
||||||
|
// batch — the sidebar would rather show a missing badge for one folder
|
||||||
|
// than nothing for any.
|
||||||
|
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
var body folderCountsBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Paths) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxInFlight = 8
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
sem = make(chan struct{}, maxInFlight)
|
||||||
|
mu sync.Mutex
|
||||||
|
counts = make(map[string]int, len(body.Paths))
|
||||||
|
)
|
||||||
|
// Seed every input key so the response always carries the same
|
||||||
|
// shape the client posted, even for paths whose lookup failed.
|
||||||
|
for _, p := range body.Paths {
|
||||||
|
counts[p] = 0
|
||||||
|
}
|
||||||
|
for _, p := range body.Paths {
|
||||||
|
path := p
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
// `path:<x>` is an exact match in PhotoPrism's q-DSL —
|
||||||
|
// it matches only photos whose `photo_path` field equals
|
||||||
|
// <x>, not descendants. The indexer always nests photos
|
||||||
|
// under YYYY/MM, so an internal tree node like `2024` has
|
||||||
|
// zero direct children and reports a count of 0 unless we
|
||||||
|
// recurse. `path:<x>*` is the documented wildcard form and
|
||||||
|
// matches both `<x>` itself (no harm if empty) and every
|
||||||
|
// `<x>/...` descendant.
|
||||||
|
//
|
||||||
|
// `merged=false` still returns one row per File on disk,
|
||||||
|
// so HEIC + companion JPG count twice unless we dedupe by
|
||||||
|
// UID — which is what the old client-side code did, and
|
||||||
|
// what we keep doing here.
|
||||||
|
q := url.QueryEscape(`path:"` + path + `*"`)
|
||||||
|
const pageSize = 1000
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||||||
|
fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=false&q=%s", pageSize, offset, q),
|
||||||
|
token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
slog.Warn("folder.counts: pp call failed",
|
||||||
|
"path", path,
|
||||||
|
"offset", offset,
|
||||||
|
"err", err,
|
||||||
|
"status", func() int {
|
||||||
|
if resp != nil {
|
||||||
|
return resp.Status
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []folderCountsRow
|
||||||
|
if err := json.Unmarshal(resp.Body, &rows); err != nil {
|
||||||
|
slog.Warn("folder.counts: parse failed", "path", path, "offset", offset, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.UID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.UID] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(rows) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
counts[path] = len(seen)
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
c.JSON(http.StatusOK, counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireReindex wraps pp.reindex with logging and a detached context so
|
||||||
|
// it can run in a goroutine after the response has gone out. The Node
|
||||||
|
// prototype kicks reindex with `void reindex(...)` and never awaits;
|
||||||
|
// matching that here keeps the apparent latency of mutating endpoints
|
||||||
|
// low (PhotoPrism's index can take seconds on a big folder).
|
||||||
|
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
|
||||||
|
// pp.call's client already enforces a 60s timeout, so the parent
|
||||||
|
// context can be detached from the request — the handler has long
|
||||||
|
// since written its response.
|
||||||
|
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
|
||||||
|
slog.Warn("reindex failed", "path", parentRel, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
262
sidecar/handlers_heap.go
Normal file
262
sidecar/handlers_heap.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type heapConvertBody struct {
|
||||||
|
TargetFolder string `json:"targetFolder"`
|
||||||
|
Mode string `json:"mode"` // "move" or "copy"
|
||||||
|
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
||||||
|
DeleteHeap bool `json:"deleteHeap"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heapPhoto struct {
|
||||||
|
UID string `json:"UID"`
|
||||||
|
Files []ppFile `json:"Files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heapErr struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile is the os.Rename fallback for cross-device moves and the
|
||||||
|
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
|
||||||
|
// memory; preserves mode bits, sets the modification time to now (we're
|
||||||
|
// creating a new inode either way).
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
st, err := in.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
albumUID := c.Param("uid")
|
||||||
|
|
||||||
|
var body heapConvertBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := body.Mode
|
||||||
|
if mode != "copy" {
|
||||||
|
mode = "move"
|
||||||
|
}
|
||||||
|
deleteHeap := mode == "move" && body.DeleteHeap
|
||||||
|
|
||||||
|
var subfolder string
|
||||||
|
if body.Subfolder != "" {
|
||||||
|
s, ok := sanitizeFilename(body.Subfolder)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subfolder = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve destination. resolveUnderRoot ensures the target lives
|
||||||
|
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
||||||
|
// Empty / "/" / "." are valid here — they mean "drop these into
|
||||||
|
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
|
||||||
|
// rejects those for safety, so handle the root case explicitly.
|
||||||
|
var targetAbs string
|
||||||
|
trimmed := strings.Trim(body.TargetFolder, "/")
|
||||||
|
if trimmed == "" || trimmed == "." {
|
||||||
|
targetAbs = cfg.OriginalsRoot
|
||||||
|
} else {
|
||||||
|
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetAbs = abs
|
||||||
|
}
|
||||||
|
destAbs := targetAbs
|
||||||
|
if subfolder != "" {
|
||||||
|
destAbs = filepath.Join(targetAbs, subfolder)
|
||||||
|
if err := os.MkdirAll(destAbs, 0o755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the heap's photos via the q=album:UID query. count=1000 covers
|
||||||
|
// every realistic heap; merged=true expands stacked variants so we
|
||||||
|
// move the JPG/HEIC sibling alongside the primary.
|
||||||
|
q := url.QueryEscape("album:" + albumUID)
|
||||||
|
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !resp.OK {
|
||||||
|
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var photos []heapPhoto
|
||||||
|
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceParents := map[string]struct{}{}
|
||||||
|
errs := []heapErr{}
|
||||||
|
moved, copied := 0, 0
|
||||||
|
|
||||||
|
for _, photo := range photos {
|
||||||
|
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||||
|
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
||||||
|
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
||||||
|
// — moving that path would fail "file missing on disk" every
|
||||||
|
// time. Prefer the primary that lives in originals (Root=="/")
|
||||||
|
// and fall back to the first originals-rooted file. PhotoPrism
|
||||||
|
// regenerates sidecars on reindex, so they don't need to follow.
|
||||||
|
var file ppFile
|
||||||
|
found := false
|
||||||
|
for _, f := range photo.Files {
|
||||||
|
if f.Root == "/" && f.Primary {
|
||||||
|
file, found = f, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
for _, f := range photo.Files {
|
||||||
|
if f.Root == "/" {
|
||||||
|
file, found = f, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srcRel := file.Name
|
||||||
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||||
|
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st, err := os.Stat(srcAbs)
|
||||||
|
if err != nil || !st.Mode().IsRegular() {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Dir(srcAbs) == destAbs {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||||
|
if !ok {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dstAbs := filepath.Join(destAbs, name)
|
||||||
|
if mode == "move" {
|
||||||
|
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||||||
|
// Cross-device renames fail with EXDEV — fall back to
|
||||||
|
// copy+remove so a library that spans filesystems still
|
||||||
|
// works.
|
||||||
|
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
} else {
|
||||||
|
if err := copyFile(srcAbs, dstAbs); err != nil {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied++
|
||||||
|
}
|
||||||
|
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex the destination + every source parent so PhotoPrism's
|
||||||
|
// DB catches up. We block on these so the response only goes out
|
||||||
|
// after the index reflects the move — callers (the frontend's
|
||||||
|
// invalidateQueries refetch in particular) need the next /photos
|
||||||
|
// fetch to return the moved files, otherwise the folder view
|
||||||
|
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
||||||
|
// internally; running them sequentially matches that contract
|
||||||
|
// without surprising the server.
|
||||||
|
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||||
|
paths := map[string]struct{}{destRel: {}}
|
||||||
|
for p := range sourceParents {
|
||||||
|
paths[p] = struct{}{}
|
||||||
|
}
|
||||||
|
if subfolder != "" {
|
||||||
|
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
||||||
|
paths[parent] = struct{}{}
|
||||||
|
}
|
||||||
|
for p := range paths {
|
||||||
|
reindex := "/"
|
||||||
|
if p != "" && p != "." {
|
||||||
|
reindex = "/" + p
|
||||||
|
}
|
||||||
|
fireReindex(cfg, pp, token, reindex)
|
||||||
|
}
|
||||||
|
|
||||||
|
heapDeleted := false
|
||||||
|
if deleteHeap {
|
||||||
|
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
||||||
|
if err == nil && r.OK {
|
||||||
|
heapDeleted = true
|
||||||
|
} else if err != nil {
|
||||||
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
||||||
|
} else {
|
||||||
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("heap.convert",
|
||||||
|
"album", albumUID,
|
||||||
|
"mode", mode,
|
||||||
|
"moved", moved,
|
||||||
|
"copied", copied,
|
||||||
|
"errors", len(errs),
|
||||||
|
"heap_deleted", heapDeleted,
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"moved": moved,
|
||||||
|
"copied": copied,
|
||||||
|
"errors": errs,
|
||||||
|
"heap_deleted": heapDeleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
206
sidecar/handlers_marks.go
Normal file
206
sidecar/handlers_marks.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validColors is the four-color palette mule-image always shipped. The
|
||||||
|
// empty string is the explicit "clear color" sentinel.
|
||||||
|
var validColors = map[string]struct{}{
|
||||||
|
"red": {},
|
||||||
|
"orange": {},
|
||||||
|
"yellow": {},
|
||||||
|
"green": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// markPatch is the request body for all three mutating mark endpoints.
|
||||||
|
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
|
||||||
|
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
|
||||||
|
// alone must NOT wipe an existing rating.
|
||||||
|
type markPatch struct {
|
||||||
|
Rating *int `json:"rating,omitempty"`
|
||||||
|
Color *string `json:"color,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *markPatch) sanitize() error {
|
||||||
|
if p.Rating != nil {
|
||||||
|
r := *p.Rating
|
||||||
|
if r < 0 || r > 5 {
|
||||||
|
return errors.New("rating out of range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Color != nil {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(*p.Color))
|
||||||
|
if c != "" {
|
||||||
|
if _, ok := validColors[c]; !ok {
|
||||||
|
return errors.New("invalid color")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*p.Color = c
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply merges the patch onto an existing row (or a fresh zero-value
|
||||||
|
// Mark for an upsert). Returns true if anything in the row still has a
|
||||||
|
// non-empty value — false signals "delete the row" to the caller.
|
||||||
|
func (p *markPatch) apply(m *Mark) bool {
|
||||||
|
if p.Rating != nil {
|
||||||
|
if *p.Rating > 0 {
|
||||||
|
r := *p.Rating
|
||||||
|
m.Rating = &r
|
||||||
|
} else {
|
||||||
|
m.Rating = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Color != nil {
|
||||||
|
if *p.Color != "" {
|
||||||
|
c := *p.Color
|
||||||
|
m.Color = &c
|
||||||
|
} else {
|
||||||
|
m.Color = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.Rating != nil || (m.Color != nil && *m.Color != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// allMarksJSON renders the entire `marks` table as the wire shape
|
||||||
|
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
|
||||||
|
// GET /photos/marks which the web client calls once on session start.
|
||||||
|
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
||||||
|
var rows []Mark
|
||||||
|
if err := db.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]map[string]any, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out[rows[i].PhotoUID] = rows[i].asJSON()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
marks, err := allMarksJSON(db)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, marks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := c.Param("uid")
|
||||||
|
var m Mark
|
||||||
|
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, m.asJSON())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsert applies the patch and writes back. Returns the resulting JSON
|
||||||
|
// shape (empty map if the row was deleted).
|
||||||
|
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
|
||||||
|
var m Mark
|
||||||
|
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.PhotoUID = uid
|
||||||
|
keep := patch.apply(&m)
|
||||||
|
m.UpdatedAt = time.Now().UTC()
|
||||||
|
if !keep {
|
||||||
|
// Drop the row entirely so a re-fetch returns {}.
|
||||||
|
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
if err := db.Save(&m).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.asJSON(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := c.Param("uid")
|
||||||
|
var patch markPatch
|
||||||
|
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := patch.sanitize(); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := upsert(db, uid, &patch)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type bulkBody struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
Patch markPatch `json:"patch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body bulkBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.IDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := body.Patch.sanitize(); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applied := make(map[string]map[string]any, len(body.IDs))
|
||||||
|
// Single transaction so a partial failure rolls back. The client
|
||||||
|
// expects atomic semantics for a bulk star/colour stamp.
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, uid := range body.IDs {
|
||||||
|
if uid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out, err := upsert(tx, uid, &body.Patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applied[uid] = out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"count": len(applied),
|
||||||
|
"marks": applied,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
143
sidecar/handlers_rename.go
Normal file
143
sidecar/handlers_rename.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renameBody mirrors the Node prototype's wire contract — a single
|
||||||
|
// `newName` field carrying the bare basename (no slashes).
|
||||||
|
type renameBody struct {
|
||||||
|
NewName string `json:"newName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ppPhoto is the partial PhotoPrism photo shape we need to find the
|
||||||
|
// primary file's on-disk location. Anything we don't read stays
|
||||||
|
// unspecified so version drift across PhotoPrism builds doesn't break
|
||||||
|
// JSON unmarshalling.
|
||||||
|
type ppPhoto struct {
|
||||||
|
Files []ppFile `json:"Files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ppFile struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Root string `json:"Root"`
|
||||||
|
Primary bool `json:"Primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryFileOf(p *ppPhoto) (ppFile, bool) {
|
||||||
|
if p == nil {
|
||||||
|
return ppFile{}, false
|
||||||
|
}
|
||||||
|
for _, f := range p.Files {
|
||||||
|
if f.Primary {
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.Files) > 0 {
|
||||||
|
return p.Files[0], true
|
||||||
|
}
|
||||||
|
return ppFile{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
photoUID := c.Param("uid")
|
||||||
|
|
||||||
|
var body renameBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newName, ok := sanitizeFilename(body.NewName)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the photo so we can resolve Files[0].Root + Name into a
|
||||||
|
// concrete on-disk path. PhotoPrism has no "file by UID" endpoint
|
||||||
|
// in this build, so the single-photo lookup is the cheapest path.
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !resp.OK {
|
||||||
|
c.JSON(resp.Status, gin.H{"error": "photo not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var photo ppPhoto
|
||||||
|
if err := json.Unmarshal(resp.Body, &photo); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, ok := primaryFileOf(&photo)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root := strings.TrimPrefix(file.Root, "/")
|
||||||
|
if root == "" || root == "/" {
|
||||||
|
root = ""
|
||||||
|
}
|
||||||
|
relPath := filepath.Join(root, file.Name)
|
||||||
|
oldAbs := filepath.Join(cfg.OriginalsRoot, relPath)
|
||||||
|
|
||||||
|
if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(oldAbs)
|
||||||
|
if err != nil || !st.Mode().IsRegular() {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
|
||||||
|
if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(newAbs); err == nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"})
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newRel := filepath.Join(root, newName)
|
||||||
|
slog.Info("rename", "from", relPath, "to", newRel)
|
||||||
|
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger reindex on the parent so PhotoPrism picks up the new
|
||||||
|
// filename and drops the orphan row for the old name. Best-effort.
|
||||||
|
reindexPath := "/"
|
||||||
|
if root != "" {
|
||||||
|
reindexPath = "/" + root
|
||||||
|
}
|
||||||
|
if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil {
|
||||||
|
slog.Warn("rename reindex failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"oldName": file.Name,
|
||||||
|
"newName": newName,
|
||||||
|
"oldRelPath": relPath,
|
||||||
|
"newRelPath": newRel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
120
sidecar/main.go
Normal file
120
sidecar/main.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// mule-sidecar — Go service for endpoints PhotoPrism does not expose.
|
||||||
|
//
|
||||||
|
// Ports the Node prototype (server.mjs) to the stack the merge plan calls
|
||||||
|
// out: Go + Gin + GORM + MariaDB. Same wire contract as the prototype so
|
||||||
|
// the SvelteKit web client doesn't need to change.
|
||||||
|
//
|
||||||
|
// Auth model is unchanged: the caller's X-Auth-Token is the only authority.
|
||||||
|
// requireSession validates it against PhotoPrism's /api/v1/photos before
|
||||||
|
// any destructive op runs.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
})))
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(cfg.DSN)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("db open", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
pp := newPPClient(cfg.PhotoprismBaseURL)
|
||||||
|
|
||||||
|
// Apply any declared username→BasePath mapping to PhotoPrism's
|
||||||
|
// auth_users table. Runs immediately + every 60s thereafter so a
|
||||||
|
// user who logs in after the sidecar booted still gets their
|
||||||
|
// BasePath wired without an admin restart.
|
||||||
|
startUserBasepathReconciler(cfg)
|
||||||
|
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
// Keep `%2F` literal in path params so callers can pass URL-encoded
|
||||||
|
// nested folder paths (e.g. `foo%2Fbar`) without the router splitting
|
||||||
|
// them into separate segments. Handlers decode via url.PathUnescape.
|
||||||
|
r.UseRawPath = true
|
||||||
|
r.UnescapePathValues = false
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// Health probe — unauthenticated so a process supervisor can call it
|
||||||
|
// without needing PhotoPrism to be reachable.
|
||||||
|
r.GET("/api/sidecar/healthz", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"originalsRoot": cfg.OriginalsRoot,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Every other endpoint runs behind the session gate. Mounting them
|
||||||
|
// under one group keeps the middleware wiring obvious.
|
||||||
|
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||||
|
{
|
||||||
|
auth.GET("/photos/marks", handleMarksAll(db))
|
||||||
|
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||||
|
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||||
|
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||||
|
|
||||||
|
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||||
|
|
||||||
|
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||||
|
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||||
|
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||||
|
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||||
|
|
||||||
|
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||||
|
|
||||||
|
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||||
|
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: r,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown so an in-flight duplicate scan or heap convert
|
||||||
|
// gets a chance to finish (or at least flush logs) on SIGTERM.
|
||||||
|
idleClosed := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigs
|
||||||
|
slog.Info("shutdown signal received")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(ctx)
|
||||||
|
close(idleClosed)
|
||||||
|
}()
|
||||||
|
|
||||||
|
slog.Info("mule-sidecar listening",
|
||||||
|
"addr", "http://"+addr,
|
||||||
|
"originals", cfg.OriginalsRoot,
|
||||||
|
)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
slog.Error("listen", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
<-idleClosed
|
||||||
|
}
|
||||||
|
|
||||||
119
sidecar/pp.go
Normal file
119
sidecar/pp.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ppClient is the thin HTTP wrapper around PhotoPrism's /api/v1. It is
|
||||||
|
// deliberately *not* shared across requests with caching: each handler
|
||||||
|
// forwards the caller's X-Auth-Token, so a single shared http.Client (we
|
||||||
|
// reuse the stdlib default) plus per-call header injection is all we need.
|
||||||
|
type ppClient struct {
|
||||||
|
base string
|
||||||
|
h *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPPClient(base string) *ppClient {
|
||||||
|
return &ppClient{
|
||||||
|
base: base,
|
||||||
|
h: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ppResp is the trimmed projection of an HTTP response that callers
|
||||||
|
// actually consume. Status + raw body are exposed so handlers can mirror
|
||||||
|
// PhotoPrism's status code or parse the body themselves. Header is
|
||||||
|
// retained for callers that need `X-Count` / `X-Limit` / `X-Offset` on
|
||||||
|
// list endpoints — PhotoPrism exposes total-match counts there.
|
||||||
|
type ppResp struct {
|
||||||
|
OK bool
|
||||||
|
Status int
|
||||||
|
Body []byte
|
||||||
|
Header http.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
// call issues an authenticated request against PhotoPrism. body is
|
||||||
|
// optional; pass nil for GET/DELETE. We don't JSON-decode here — callers
|
||||||
|
// know the shape they want and decode lazily.
|
||||||
|
func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body any) (*ppResp, error) {
|
||||||
|
u, err := url.Parse(c.base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel, err := url.Parse(urlPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
full := u.ResolveReference(rel).String()
|
||||||
|
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, full, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Auth-Token", token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.h.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ppResp{
|
||||||
|
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
Status: resp.StatusCode,
|
||||||
|
Body: buf,
|
||||||
|
Header: resp.Header,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSession is the cheapest probe that the supplied token is live:
|
||||||
|
// list one photo. 401 → bad/expired token. We never read the payload.
|
||||||
|
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r, err := c.call(ctx, http.MethodGet, "/api/v1/photos?count=1", token, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return r.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// reindex tells PhotoPrism to re-walk a single subpath of originals and
|
||||||
|
// reconcile its DB with the on-disk state. Callers fire this after any
|
||||||
|
// rename/create/delete so the timeline catches up. `cleanup: true` drops
|
||||||
|
// orphan rows (e.g. the row for the file's old name after a rename).
|
||||||
|
//
|
||||||
|
// Best-effort: errors are surfaced to the caller, who logs but does not
|
||||||
|
// abort — the file mutation has already happened on disk by the time
|
||||||
|
// reindex runs.
|
||||||
|
func (c *ppClient) reindex(ctx context.Context, token, parentRel string) error {
|
||||||
|
if parentRel == "" {
|
||||||
|
parentRel = "/"
|
||||||
|
}
|
||||||
|
_, err := c.call(ctx, http.MethodPost, "/api/v1/index", token, map[string]any{
|
||||||
|
"path": parentRel,
|
||||||
|
"rescan": false,
|
||||||
|
"cleanup": true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
124
sidecar/users.go
Normal file
124
sidecar/users.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhotoPrism's open-source edition doesn't expose any way to map OIDC
|
||||||
|
// claims to a per-user BasePath — every newly-registered OIDC user
|
||||||
|
// lands with `BasePath = ""`, which means PhotoPrism's ACL filter
|
||||||
|
// shows them nothing (non-admin) or everything (admin). Neither is the
|
||||||
|
// per-user library scope a homelab admin typically wants.
|
||||||
|
//
|
||||||
|
// This reconciler reads a declarative `USER_BASEPATHS` env at sidecar
|
||||||
|
// startup, formatted as `username:originals-relative-path` pairs
|
||||||
|
// separated by commas (whitespace tolerated), e.g.:
|
||||||
|
//
|
||||||
|
// USER_BASEPATHS="test:test, alice:family/alice, bob:bob"
|
||||||
|
//
|
||||||
|
// For every entry the sidecar:
|
||||||
|
// 1. Ensures the originals subdirectory exists (so PhotoPrism's path:
|
||||||
|
// filter has somewhere to point — empty dirs are fine).
|
||||||
|
// 2. UPDATEs `photoprism.auth_users.base_path` for the matching user
|
||||||
|
// row if the current value differs. Idempotent: rows already
|
||||||
|
// matching are skipped, and missing users are no-ops (they'll
|
||||||
|
// materialise when they log in via OIDC; the periodic ticker
|
||||||
|
// catches them on the next pass).
|
||||||
|
//
|
||||||
|
// A 60-second ticker keeps the mapping in lockstep with new OIDC
|
||||||
|
// registrations without needing a webhook.
|
||||||
|
|
||||||
|
func parseUserBasepaths(raw string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, p := range strings.Split(raw, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := strings.SplitN(p, ":", 2)
|
||||||
|
if len(bits) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := strings.TrimSpace(bits[0])
|
||||||
|
// Strip any leading/trailing slash so the value lands in
|
||||||
|
// auth_users.base_path the same way PhotoPrism's own user-edit
|
||||||
|
// UI persists it (relative, no slashes).
|
||||||
|
path := strings.Trim(strings.TrimSpace(bits[1]), "/")
|
||||||
|
if u == "" || path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[u] = path
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]string) error {
|
||||||
|
if len(mapping) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ppDSN == "" {
|
||||||
|
return fmt.Errorf("USER_BASEPATHS set but PP_DB_PASSWORD missing — can't reach photoprism schema")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.Open(ppDSN), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Warn),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open pp db: %w", err)
|
||||||
|
}
|
||||||
|
if sqlDB, derr := db.DB(); derr == nil {
|
||||||
|
defer sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
for username, path := range mapping {
|
||||||
|
target := filepath.Join(originalsRoot, path)
|
||||||
|
if err := os.MkdirAll(target, 0o775); err != nil {
|
||||||
|
slog.Warn("user-basepath: mkdir failed", "user", username, "path", target, "err", err)
|
||||||
|
// Continue — the DB update is still useful so PhotoPrism's
|
||||||
|
// ACL kicks in even if the directory is created later.
|
||||||
|
}
|
||||||
|
|
||||||
|
res := db.Exec(`UPDATE auth_users
|
||||||
|
SET base_path = ?
|
||||||
|
WHERE user_name = ?
|
||||||
|
AND COALESCE(base_path, '') <> ?
|
||||||
|
AND deleted_at IS NULL`,
|
||||||
|
path, username, path)
|
||||||
|
if res.Error != nil {
|
||||||
|
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
slog.Info("user-basepath: set", "user", username, "path", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startUserBasepathReconciler(cfg *Config) {
|
||||||
|
if len(cfg.UserBasepaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("user-basepath: starting reconciler", "entries", len(cfg.UserBasepaths))
|
||||||
|
apply := func() {
|
||||||
|
if err := reconcileUserBasepaths(cfg.PpDSN, cfg.OriginalsRoot, cfg.UserBasepaths); err != nil {
|
||||||
|
slog.Warn("user-basepath: reconciliation error", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(60 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
42
web/README.md
Normal file
42
web/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.15.3 create --template minimal --types ts --install npm web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
17
web/components.json
Normal file
17
web/components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "zinc"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
2674
web/package-lock.json
generated
Normal file
2674
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.57.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^25.8.0",
|
||||||
|
"svelte": "^5.55.2",
|
||||||
|
"svelte-check": "^4.4.6",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/svelte-query": "^6.1.29",
|
||||||
|
"@tanstack/svelte-virtual": "^3.13.24",
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"bits-ui": "^2.18.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-svelte": "^1.0.1",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"svelte-sonner": "^1.1.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"vidstack": "^1.12.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
web/src/app.css
Normal file
103
web/src/app.css
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* shadcn-svelte design tokens. Mirrors the canonical "new-york" preset.
|
||||||
|
* Both light + dark are declared so mode-watcher can flip between them.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tailwind v4 dropped the built-in `cursor: pointer` on buttons; restore
|
||||||
|
* it so every <button>, <summary>, and role="button" gets the click
|
||||||
|
* affordance. Disabled controls get `not-allowed` so the cursor mirrors
|
||||||
|
* the visual opacity-50 state already on most buttons.
|
||||||
|
*/
|
||||||
|
button:not(:disabled),
|
||||||
|
[role='button']:not([aria-disabled='true']),
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled,
|
||||||
|
[role='button'][aria-disabled='true'] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/src/app.d.ts
vendored
Normal file
13
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
19
web/src/app.html
Normal file
19
web/src/app.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
<!-- SVG favicon adapts to light/dark via `prefers-color-scheme`
|
||||||
|
inside the file itself; PNG remains as a fallback for browsers
|
||||||
|
that don't support SVG icons. Apple touch icon stays PNG
|
||||||
|
since iOS home-screen icons can't be SVG. -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
587
web/src/lib/actions/gridKeyNav.ts
Normal file
587
web/src/lib/actions/gridKeyNav.ts
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { batchEdit } from '$lib/services/batch';
|
||||||
|
import { invalidatePhotos } from '$lib/services/bulk';
|
||||||
|
import {
|
||||||
|
addToHeap,
|
||||||
|
approvePhoto,
|
||||||
|
batchArchive,
|
||||||
|
batchDelete,
|
||||||
|
batchRestore,
|
||||||
|
removeFromHeap,
|
||||||
|
type PpAlbum
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
|
import { queryClient } from '$lib/queryClient';
|
||||||
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
|
import {
|
||||||
|
clearBulkToFirst,
|
||||||
|
clearSelection,
|
||||||
|
focusAfter,
|
||||||
|
indexOf,
|
||||||
|
selectRange,
|
||||||
|
selection,
|
||||||
|
setAnchor,
|
||||||
|
setFocused,
|
||||||
|
toggle
|
||||||
|
} from '$lib/stores/selection.svelte';
|
||||||
|
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
|
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||||
|
*
|
||||||
|
* - `scrollToIndex`: invoked when the action's own arrow-nav lands on a
|
||||||
|
* tile that's currently windowed-out of the DOM. The host expands its
|
||||||
|
* render window and scrolls the now-mounted shell into view.
|
||||||
|
* - `onArrow`: when provided, the action delegates ALL arrow keys to the
|
||||||
|
* host instead of computing moves itself. Required for grids with
|
||||||
|
* interleaved non-tile rows (e.g. month headers): linear +/-cols math
|
||||||
|
* skips wrong because the column count of header rows is 1 (full-span),
|
||||||
|
* not the tile column count. The host owns the visual-row map and
|
||||||
|
* handles the (row, col) translation. Mirrors mule-image's
|
||||||
|
* `useGridKeyNav` pattern.
|
||||||
|
*/
|
||||||
|
export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
|
||||||
|
|
||||||
|
export interface GridKeyNavParams {
|
||||||
|
scrollToIndex?: (i: number) => void;
|
||||||
|
onArrow?: (key: ArrowKey, extending: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte `action` for the timeline grid. Owns:
|
||||||
|
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
|
||||||
|
* - Click + shift/ctrl click selection mutations
|
||||||
|
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||||
|
* x archive-toggle, u restore, s + (1–9) add to
|
||||||
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
|
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||||
|
* ⌘A selects all visible.
|
||||||
|
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
||||||
|
* keyboard shortcuts.
|
||||||
|
*
|
||||||
|
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||||
|
* 1. multi-selection set
|
||||||
|
* 2. focused tile
|
||||||
|
*/
|
||||||
|
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||||
|
let scrollToIndex = params.scrollToIndex;
|
||||||
|
let onArrow = params.onArrow;
|
||||||
|
|
||||||
|
/** Cached column count for the visible grid. Read from CSS
|
||||||
|
* (`grid-template-columns` resolves to a space-separated list of px
|
||||||
|
* sizes), invalidated by a `ResizeObserver` on the grid host. This
|
||||||
|
* keeps the read DOM-cheap regardless of how many tiles are mounted —
|
||||||
|
* critical once windowing renders only a slice of the order. */
|
||||||
|
let cachedCols: number | null = null;
|
||||||
|
let gridEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function findGrid(): HTMLElement | null {
|
||||||
|
// The `[role="group"][aria-label="Photos"]` or simply the first
|
||||||
|
// element whose computed grid-template-columns has >1 track. The
|
||||||
|
// timeline grid sits inside `node` (the action target = <main>).
|
||||||
|
if (gridEl && node.contains(gridEl)) return gridEl;
|
||||||
|
const candidate = node.querySelector<HTMLElement>('[data-photo-grid]');
|
||||||
|
if (candidate) {
|
||||||
|
gridEl = candidate;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
// Fallback: the first descendant that's display: grid with ≥2 cols.
|
||||||
|
// Avoids a hard coupling on the data-attribute in case the host
|
||||||
|
// hasn't tagged it yet.
|
||||||
|
for (const el of node.querySelectorAll<HTMLElement>('*')) {
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) {
|
||||||
|
gridEl = el;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tilesPerRow(): number {
|
||||||
|
if (cachedCols !== null) return cachedCols;
|
||||||
|
const grid = findGrid();
|
||||||
|
if (!grid) return 1;
|
||||||
|
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length;
|
||||||
|
cachedCols = Math.max(1, cols);
|
||||||
|
return cachedCols;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
// Container width changed → column count likely changed too.
|
||||||
|
// Cheaper to invalidate than to recompute; tilesPerRow recomputes
|
||||||
|
// on next access (which is per keystroke at most).
|
||||||
|
cachedCols = null;
|
||||||
|
});
|
||||||
|
ro.observe(node);
|
||||||
|
|
||||||
|
function focusedIndex(): number {
|
||||||
|
return indexOf(selection.focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the focus cursor by `delta` tiles. When the move is NOT a
|
||||||
|
* shift-extension, the anchor is bumped to the new focused tile so the
|
||||||
|
* next shift-click/arrow starts from the user's current cursor (the
|
||||||
|
* "starting photo") instead of a stale toggle/selectOnly anchor.
|
||||||
|
*
|
||||||
|
* Scroll-into-view tries the direct DOM lookup first (works pre-
|
||||||
|
* windowing AND post-windowing for tiles already in the visible
|
||||||
|
* window); if the tile isn't rendered (windowed out), defer to the
|
||||||
|
* host-provided `scrollToIndex` which expands the window. */
|
||||||
|
function moveFocus(delta: number, extending: boolean) {
|
||||||
|
if (selection.order.length === 0) return;
|
||||||
|
const cur = focusedIndex();
|
||||||
|
const next =
|
||||||
|
cur < 0
|
||||||
|
? delta > 0
|
||||||
|
? 0
|
||||||
|
: selection.order.length - 1
|
||||||
|
: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
|
||||||
|
const nextUid = selection.order[next];
|
||||||
|
// Plain arrow nav collapses any prior multi-selection down to the
|
||||||
|
// cursor: one ringed tile at a time. Shift-extend keeps `ids`
|
||||||
|
// growing from the anchor (selectRange runs after this).
|
||||||
|
if (!extending) clearSelection();
|
||||||
|
setFocused(nextUid);
|
||||||
|
if (!extending) setAnchor(nextUid);
|
||||||
|
const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
|
||||||
|
if (tile) {
|
||||||
|
tile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
} else {
|
||||||
|
scrollToIndex?.(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synthesize a target list. Multi-selection wins, then focused. */
|
||||||
|
function cullTargets(): string[] {
|
||||||
|
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||||
|
if (selection.focused) return [selection.focused];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
const verb = direction === 'restore' ? 'restore' : 'archive';
|
||||||
|
toast.message(`Nothing to ${verb}`, {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: boolean;
|
||||||
|
if (direction === 'archive') target = true;
|
||||||
|
else if (direction === 'restore') target = false;
|
||||||
|
else {
|
||||||
|
const first = cachedPhoto(ids[0]);
|
||||||
|
target = !(first?.Archived ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||||||
|
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||||||
|
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||||||
|
// back, so the toast fired but nothing moved.
|
||||||
|
try {
|
||||||
|
if (target) await batchArchive(ids);
|
||||||
|
else await batchRestore(ids);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Move focus forward before the photos query refetches, so the
|
||||||
|
// user can keep X-ing through the timeline without their cursor
|
||||||
|
// snapping back to photo[0]. Walks past every uid we just
|
||||||
|
// archived/restored — relevant when the cull targets came from a
|
||||||
|
// multi-selection rather than the single focused tile.
|
||||||
|
focusAfter(ids);
|
||||||
|
// Drop the now-stale selection set. The archived UIDs are about
|
||||||
|
// to leave the timeline on refetch, but the SvelteSet membership
|
||||||
|
// keeps the selection ring on them until then — confusing for
|
||||||
|
// the user and a footgun if they Ctrl-click to add more and end
|
||||||
|
// up re-archiving the same photos. The BulkActionBar button path
|
||||||
|
// clears for the same reason; mirror it here.
|
||||||
|
clearSelection();
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||||
|
toast.success(label);
|
||||||
|
pushUndo(label, async () => {
|
||||||
|
if (target) await batchRestore(ids);
|
||||||
|
else await batchArchive(ids);
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently delete cull targets — only callable from the archive
|
||||||
|
* section (X is rerouted away from archive-toggle there). PhotoPrism
|
||||||
|
* rejects deletion of un-archived photos with a 4xx, so the section
|
||||||
|
* gate doubles as a safety guard against accidental deletes from the
|
||||||
|
* main timeline. Confirm dialog is mandatory — no undo path exists. */
|
||||||
|
async function deleteCullTargets() {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to delete', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg =
|
||||||
|
ids.length === 1
|
||||||
|
? 'Permanently delete this photo? This cannot be undone.'
|
||||||
|
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
try {
|
||||||
|
await batchDelete(ids);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusAfter(ids);
|
||||||
|
clearSelection();
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
toast.success(`Deleted ${ids.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve cull targets — clears them out of the review pile by
|
||||||
|
* bumping each photo's quality score above PhotoPrism's review
|
||||||
|
* threshold. The op is one-way (no /unapprove route), so we don't
|
||||||
|
* push an undo entry: a re-keyed S would just be a no-op on
|
||||||
|
* already-approved photos. */
|
||||||
|
async function approveCullTargets() {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to keep', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||||
|
// Approve moves photos out of the review pile, so the same
|
||||||
|
// stale-selection trap as archive/delete applies — advance focus
|
||||||
|
// past the approved set and drop the now-irrelevant selection
|
||||||
|
// before invalidate refetches the (smaller) view.
|
||||||
|
focusAfter(ids);
|
||||||
|
clearSelection();
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||||
|
description: errors[0].message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Kept ${ids.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||||
|
// Press S: arm a short timer. A digit 1–9 within the window adds the
|
||||||
|
// cull targets to the Nth heap in the heap list. Any other key cancels
|
||||||
|
// the chord without firing. On timeout, fall back to the currently-
|
||||||
|
// viewed heap (i.e. when section==='heap'); otherwise show a hint toast.
|
||||||
|
let sChordTimer: number | null = null;
|
||||||
|
const S_CHORD_MS = 500;
|
||||||
|
|
||||||
|
function clearSChord() {
|
||||||
|
if (sChordTimer !== null) {
|
||||||
|
window.clearTimeout(sChordTimer);
|
||||||
|
sChordTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCullTargetsToHeap(heap: PpAlbum) {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to add', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { added } = await addToHeap(heap.UID, ids);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
// PhotoPrism returns 200 even when nothing was added — distinguish
|
||||||
|
// "really added N" from "skipped all N" so the toast tells the
|
||||||
|
// truth.
|
||||||
|
if (added.length === 0) {
|
||||||
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
|
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (added.length < ids.length) {
|
||||||
|
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||||
|
description: 'The rest were already in this heap.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||||
|
}
|
||||||
|
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||||
|
await removeFromHeap(heap.UID, added);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCullTargetsToHeapByIndex(idx: number) {
|
||||||
|
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
|
||||||
|
if (idx < 1 || idx > heaps.length) {
|
||||||
|
toast.message(`No heap #${idx}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCullTargetsToActiveHeap() {
|
||||||
|
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||||
|
toast.message('Press S then 1–9 to pick a heap');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
|
||||||
|
const heap = heaps.find((h) => h.UID === filters.heapUid);
|
||||||
|
if (!heap) {
|
||||||
|
toast.message('Active heap not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await addCullTargetsToHeap(heap);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onKey(e: KeyboardEvent) {
|
||||||
|
// Don't hijack typing inside form fields.
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||||
|
|
||||||
|
// Modal owns arrow / Escape / Space while it's open — it handles
|
||||||
|
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
||||||
|
// keys (X/S/U/A/Z) still pass through because they target the
|
||||||
|
// shared selection store and work the same in either context.
|
||||||
|
if (view.previewOpen) {
|
||||||
|
if (
|
||||||
|
e.key === 'ArrowLeft' ||
|
||||||
|
e.key === 'ArrowRight' ||
|
||||||
|
e.key === 'ArrowUp' ||
|
||||||
|
e.key === 'ArrowDown' ||
|
||||||
|
e.key === 'Escape' ||
|
||||||
|
e.key === ' ' ||
|
||||||
|
e.code === 'Space'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S+digit chord. A digit 1–9 within the chord window consumes the key
|
||||||
|
// and fires add-to-heap-N. Any other key cancels the chord without
|
||||||
|
// firing the default active-heap action — the user switched intent —
|
||||||
|
// and falls through to normal handling for that key.
|
||||||
|
if (sChordTimer !== null) {
|
||||||
|
if (/^[1-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearSChord();
|
||||||
|
void addCullTargetsToHeapByIndex(parseInt(e.key, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSChord();
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
|
const shift = e.shiftKey;
|
||||||
|
|
||||||
|
// Space on a focused tile opens the full-screen preview modal.
|
||||||
|
// Matches the dblclick gesture so the user has both keyboard and
|
||||||
|
// mouse paths to the same surface. `e.code === 'Space'` covers
|
||||||
|
// layouts where `e.key` is the dead-key combining mark.
|
||||||
|
if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) {
|
||||||
|
if (selection.focused) {
|
||||||
|
e.preventDefault();
|
||||||
|
openPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid nav keys ────────────────────────────────────────────────────
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
if (onArrow) {
|
||||||
|
// Host owns the visual-row map (needed for grids with
|
||||||
|
// interleaved headers). The host calls setFocused +
|
||||||
|
// scrollToIndex + selectRange-on-shift itself.
|
||||||
|
onArrow(e.key, shift);
|
||||||
|
} else {
|
||||||
|
const delta =
|
||||||
|
e.key === 'ArrowLeft'
|
||||||
|
? -1
|
||||||
|
: e.key === 'ArrowRight'
|
||||||
|
? 1
|
||||||
|
: e.key === 'ArrowUp'
|
||||||
|
? -tilesPerRow()
|
||||||
|
: tilesPerRow();
|
||||||
|
moveFocus(delta, shift);
|
||||||
|
if (shift && selection.focused) selectRange(selection.focused);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
// First Esc collapses a multi-selection back to single-focus
|
||||||
|
// on its first member — the user's "starting photo" stays
|
||||||
|
// visible instead of vanishing. Only when there's no bulk
|
||||||
|
// does Esc fully dismiss focus.
|
||||||
|
if (clearBulkToFirst()) return;
|
||||||
|
clearSelection();
|
||||||
|
setFocused(null);
|
||||||
|
return;
|
||||||
|
case 'Tab':
|
||||||
|
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||||
|
// Browsers reserve Tab for focus traversal — preventDefault
|
||||||
|
// here is fine because the grid owns this surface.
|
||||||
|
e.preventDefault();
|
||||||
|
toggleLeftSidebar();
|
||||||
|
return;
|
||||||
|
case 'i':
|
||||||
|
case 'I':
|
||||||
|
if (!meta && !shift) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleRightSidebar();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'b':
|
||||||
|
case 'B':
|
||||||
|
if (!meta && !shift) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleLeftSidebar();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'z':
|
||||||
|
case 'Z':
|
||||||
|
if (meta) {
|
||||||
|
e.preventDefault();
|
||||||
|
const entry = await popAndRun();
|
||||||
|
if (entry) toast.success(`Undone: ${entry.label}`);
|
||||||
|
else toast.message('Nothing to undo');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
if (meta) {
|
||||||
|
e.preventDefault();
|
||||||
|
for (const id of selection.order) selection.ids.add(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shift) return;
|
||||||
|
// Bare `a` on the EXIF Stripped review tab fires the same
|
||||||
|
// "Accept date & Keep" flow as the bar button. Mirrors the
|
||||||
|
// bar's all-targets-have-a-suggestion gate so the shortcut
|
||||||
|
// can't silently approve photos without a date fix.
|
||||||
|
if (
|
||||||
|
filters.section === 'review' &&
|
||||||
|
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
|
||||||
|
) {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
const p = cachedPhoto(id);
|
||||||
|
if (!p) return;
|
||||||
|
const { fileName, path } = photoNameAndDir(p);
|
||||||
|
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
void acceptDateAndKeep(ids);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'x':
|
||||||
|
case 'X':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
// Archive section: X becomes permanent delete (Keep/Delete
|
||||||
|
// is the binary flow there, mirroring Review's Keep/Archive).
|
||||||
|
// Everywhere else X toggles archive on the cull targets.
|
||||||
|
if (filters.section === 'archive') {
|
||||||
|
void deleteCullTargets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void toggleArchive('toggle');
|
||||||
|
return;
|
||||||
|
case 'u':
|
||||||
|
case 'U':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleArchive('restore');
|
||||||
|
return;
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
// Review section repurposes S as the Keep affordance —
|
||||||
|
// matches the BulkActionBar button and keeps the binary
|
||||||
|
// Keep/Archive flow on home-row keys (S/X). The heap chord
|
||||||
|
// is meaningless here anyway (review photos can't sensibly
|
||||||
|
// be filed before they're approved).
|
||||||
|
if (filters.section === 'review') {
|
||||||
|
void approveCullTargets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Archive section: S = Keep = restore back to the timeline
|
||||||
|
// (inverse of Delete on X). Same rationale as review —
|
||||||
|
// heap-filing an archived photo isn't a flow that fits the
|
||||||
|
// section's intent.
|
||||||
|
if (filters.section === 'archive') {
|
||||||
|
void toggleArchive('restore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||||
|
// otherwise we fall back to the currently-viewed heap.
|
||||||
|
clearSChord();
|
||||||
|
sChordTimer = window.setTimeout(() => {
|
||||||
|
sChordTimer = null;
|
||||||
|
void addCullTargetsToActiveHeap();
|
||||||
|
}, S_CHORD_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
const tile = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-tile]');
|
||||||
|
if (!tile) return;
|
||||||
|
const uid = tile.dataset.uid;
|
||||||
|
if (!uid) return;
|
||||||
|
// Modifier clicks are the only paths this document-level handler
|
||||||
|
// owns. Plain clicks bubble to the tile button's onclick, which
|
||||||
|
// reduces selection to just that tile.
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectRange(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
} else if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('click', onClick);
|
||||||
|
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
|
||||||
|
// immediately on page load regardless of which element holds focus.
|
||||||
|
// The filter inside `onKey` keeps form-field typing safe.
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: GridKeyNavParams = {}) {
|
||||||
|
scrollToIndex = next.scrollToIndex;
|
||||||
|
onArrow = next.onArrow;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
clearSChord();
|
||||||
|
node.removeEventListener('click', onClick);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
ro.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
79
web/src/lib/actions/nearBottom.ts
Normal file
79
web/src/lib/actions/nearBottom.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Fires `onHit` whenever the attached element scrolls near the bottom of
|
||||||
|
* its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from
|
||||||
|
* `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel
|
||||||
|
* div with a rootMargin equal to ~4 viewport heights, so the next page is
|
||||||
|
* fetched well before the user actually reaches the end.
|
||||||
|
*
|
||||||
|
* Usage: attach to a sentinel <div /> placed at the bottom of the scroll
|
||||||
|
* area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`).
|
||||||
|
*
|
||||||
|
* <div use:nearBottom={{ onHit: fetchNextPage, enabled: canFetch }} />
|
||||||
|
*/
|
||||||
|
export interface NearBottomParams {
|
||||||
|
onHit: () => void;
|
||||||
|
/** When false the observer ignores intersections (use for the
|
||||||
|
* hasNextPage + !isFetchingNextPage gate). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`;
|
||||||
|
* we default to the same. Caller can pass a number for tests. */
|
||||||
|
preloadPx?: number;
|
||||||
|
/** Optional scroll root (defaults to the viewport). Pass the
|
||||||
|
* scrolling ancestor when the page itself doesn't scroll, which is
|
||||||
|
* our case — the timeline scrolls inside `<main>`. */
|
||||||
|
root?: Element | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
||||||
|
let current: NearBottomParams = params;
|
||||||
|
let io: IntersectionObserver | null = null;
|
||||||
|
// IntersectionObserver only emits on state changes. With a 4-viewport
|
||||||
|
// preload zone, the sentinel typically stays continuously intersecting
|
||||||
|
// across a whole fetchNextPage cycle: enabled flips false (fetching),
|
||||||
|
// the IO callback runs but no-ops, enabled flips back true — and no new
|
||||||
|
// event is emitted because the intersection state never changed. We'd
|
||||||
|
// stall mid-pagination. Remember the last reported intersection so the
|
||||||
|
// next `enabled` rising edge can re-fire manually.
|
||||||
|
let lastIntersecting = false;
|
||||||
|
|
||||||
|
function buildObserver(p: NearBottomParams) {
|
||||||
|
io?.disconnect();
|
||||||
|
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
lastIntersecting = e.isIntersecting;
|
||||||
|
}
|
||||||
|
if (lastIntersecting && current.enabled) current.onHit();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: p.root ?? null,
|
||||||
|
// Inflate the root's bottom edge so we trip well before
|
||||||
|
// the sentinel actually enters the viewport.
|
||||||
|
rootMargin: `0px 0px ${preload}px 0px`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
io.observe(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildObserver(current);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: NearBottomParams) {
|
||||||
|
const rootChanged = next.root !== current.root;
|
||||||
|
const preloadChanged = next.preloadPx !== current.preloadPx;
|
||||||
|
const enabledRose = !current.enabled && !!next.enabled;
|
||||||
|
current = next;
|
||||||
|
if (rootChanged || preloadChanged) {
|
||||||
|
buildObserver(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `enabled` rising while the sentinel is still in the preload
|
||||||
|
// zone — no IO event coming, so fire manually.
|
||||||
|
if (enabledRose && lastIntersecting) current.onHit();
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
io?.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
78
web/src/lib/actions/resizable.ts
Normal file
78
web/src/lib/actions/resizable.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Drag-to-resize Svelte action. Attaches pointerdown to the host element
|
||||||
|
* (a thin handle on the inner edge of a sidebar) and writes the new width
|
||||||
|
* back via the supplied setter. The pointer is captured so the drag keeps
|
||||||
|
* tracking when the cursor leaves the handle.
|
||||||
|
*
|
||||||
|
* edge: 'right' — handle on the right edge of the panel; drag right widens
|
||||||
|
* edge: 'left' — handle on the left edge of the panel; drag left widens
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <div use:resizable={{ edge: 'right', getWidth: () => view.leftSidebarWidth, setWidth: setLeftSidebarWidth }} />
|
||||||
|
*/
|
||||||
|
export interface ResizableParams {
|
||||||
|
edge: 'right' | 'left';
|
||||||
|
getWidth: () => number;
|
||||||
|
setWidth: (px: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizable(node: HTMLElement, initial: ResizableParams) {
|
||||||
|
let params = initial;
|
||||||
|
let pointerId = -1;
|
||||||
|
let startX = 0;
|
||||||
|
let startWidth = 0;
|
||||||
|
|
||||||
|
function onDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
pointerId = e.pointerId;
|
||||||
|
startX = e.clientX;
|
||||||
|
startWidth = params.getWidth();
|
||||||
|
node.setPointerCapture(pointerId);
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
node.addEventListener('pointermove', onMove);
|
||||||
|
node.addEventListener('pointerup', onUp);
|
||||||
|
node.addEventListener('pointercancel', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: PointerEvent) {
|
||||||
|
if (e.pointerId !== pointerId) return;
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
const delta = params.edge === 'right' ? dx : -dx;
|
||||||
|
params.setWidth(startWidth + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(e: PointerEvent) {
|
||||||
|
if (pointerId === -1) return;
|
||||||
|
try {
|
||||||
|
node.releasePointerCapture(pointerId);
|
||||||
|
} catch {
|
||||||
|
// Pointer may already be released; ignore.
|
||||||
|
}
|
||||||
|
pointerId = -1;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
node.removeEventListener('pointermove', onMove);
|
||||||
|
node.removeEventListener('pointerup', onUp);
|
||||||
|
node.removeEventListener('pointercancel', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDoubleClick() {
|
||||||
|
// Reset to the current default-ish midpoint. Callers can override by
|
||||||
|
// providing their own dblclick handler; we just stop pointer events
|
||||||
|
// from leaking up so the page underneath doesn't react.
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('pointerdown', onDown);
|
||||||
|
node.addEventListener('dblclick', onDoubleClick);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: ResizableParams) {
|
||||||
|
params = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('pointerdown', onDown);
|
||||||
|
node.removeEventListener('dblclick', onDoubleClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
164
web/src/lib/actions/visibleRange.ts
Normal file
164
web/src/lib/actions/visibleRange.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Tracks the first/last visible tile indices inside the attached scroll
|
||||||
|
* container so the host can render only `[first - BUFFER, last + BUFFER]`
|
||||||
|
* and leave the rest unmounted.
|
||||||
|
*
|
||||||
|
* Implementation: rAF-throttled scroll listener that scans the shell
|
||||||
|
* elements (every photo renders a `[data-uid-shell]` div regardless of
|
||||||
|
* the windowing window) and reports the first/last shell whose bounding
|
||||||
|
* rect intersects the scroll root.
|
||||||
|
*
|
||||||
|
* Why not an IntersectionObserver? Two real-world breakages:
|
||||||
|
*
|
||||||
|
* 1. Late tile registration on remount. With cached photo data, the
|
||||||
|
* host's child shells mount in the same pass as the scroll root,
|
||||||
|
* racing the observer setup → registrations silently drop, observer
|
||||||
|
* sees nothing, the window never updates.
|
||||||
|
* 2. Observer dead zone on fast scroll. The observer fires only when a
|
||||||
|
* sample tile crosses the root boundary. If the user flicks the
|
||||||
|
* scroll faster than the host's buffer can extend the mounted set,
|
||||||
|
* every sample tile leaves the viewport before the next one is
|
||||||
|
* mounted, the host's `onChange` stops firing, and the timeline
|
||||||
|
* goes blank.
|
||||||
|
*
|
||||||
|
* Querying shells directly sidesteps both: shells are always mounted,
|
||||||
|
* the scan happens on every scroll tick, and the result is the true
|
||||||
|
* first/last regardless of how fast the user dragged.
|
||||||
|
*
|
||||||
|
* Use it like:
|
||||||
|
*
|
||||||
|
* <main use:visibleRange={{
|
||||||
|
* onChange: (f, l) => { range.first = f; range.last = l; }
|
||||||
|
* }}>
|
||||||
|
* {#each photos as p, i}
|
||||||
|
* <div data-uid-shell={p.UID}>
|
||||||
|
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
|
||||||
|
* <Tile {p} />
|
||||||
|
* {/if}
|
||||||
|
* </div>
|
||||||
|
* {/each}
|
||||||
|
* </main>
|
||||||
|
*
|
||||||
|
* The `register`/`unregister` handle is kept for backwards compatibility
|
||||||
|
* with the existing host but is now a no-op — shell-based scanning
|
||||||
|
* doesn't need per-tile enrolment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VisibleRangeParams {
|
||||||
|
onChange: (first: number, last: number) => void;
|
||||||
|
/** Retained for backwards compatibility — shell-scan ignores it. */
|
||||||
|
sampleEvery?: number;
|
||||||
|
/** CSS margin string for legacy callers; shell-scan ignores it. */
|
||||||
|
rootMargin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibleRangeHandle {
|
||||||
|
register(el: HTMLElement, index: number): void;
|
||||||
|
unregister(el: HTMLElement): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
|
||||||
|
let current = params;
|
||||||
|
let lastFirst = -1;
|
||||||
|
let lastLast = -1;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
// Between frames the visible band can shift by at most ~one viewport
|
||||||
|
// of shells (any further and it's a programmatic jump, which falls
|
||||||
|
// back to a full sweep below). 300 covers a fast-flick on the densest
|
||||||
|
// thumbnail preset (XS) plus a buffer; tightening it further saves
|
||||||
|
// little and risks missing the new band after a quick scroll-wheel
|
||||||
|
// flick.
|
||||||
|
const SCAN_MARGIN = 300;
|
||||||
|
|
||||||
|
function scanFrom(
|
||||||
|
shells: NodeListOf<HTMLElement>,
|
||||||
|
rootRect: DOMRect,
|
||||||
|
start: number
|
||||||
|
): [number, number] {
|
||||||
|
let first = -1;
|
||||||
|
let last = -1;
|
||||||
|
for (let i = start; i < shells.length; i++) {
|
||||||
|
const r = shells[i].getBoundingClientRect();
|
||||||
|
if (r.bottom < rootRect.top) continue;
|
||||||
|
if (r.top > rootRect.bottom) break;
|
||||||
|
if (first === -1) first = i;
|
||||||
|
last = i;
|
||||||
|
}
|
||||||
|
return [first, last];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute() {
|
||||||
|
rafId = null;
|
||||||
|
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
|
||||||
|
if (shells.length === 0) return;
|
||||||
|
const rootRect = node.getBoundingClientRect();
|
||||||
|
// Anchor the sweep around the previous result so a deep timeline
|
||||||
|
// doesn't pay `getBoundingClientRect()` × (every-shell-above-the-
|
||||||
|
// viewport) on every scroll tick. Previous loop scanned from 0
|
||||||
|
// each time → quadratic-feeling on long sessions with 1000+
|
||||||
|
// loaded photos.
|
||||||
|
const startHint = lastFirst >= 0 ? Math.max(0, lastFirst - SCAN_MARGIN) : 0;
|
||||||
|
let [first, last] = scanFrom(shells, rootRect, startHint);
|
||||||
|
// Bounded scan missed the band — user scrolled past the hint
|
||||||
|
// margin (programmatic jump, filter-reset reflow, etc.). Fall
|
||||||
|
// back to a single full sweep to re-anchor. Costs the same as
|
||||||
|
// the old behavior on this one frame, then bounded scans take
|
||||||
|
// over again.
|
||||||
|
if (first === -1 && startHint > 0) {
|
||||||
|
[first, last] = scanFrom(shells, rootRect, 0);
|
||||||
|
}
|
||||||
|
if (first === -1 || last === -1) return;
|
||||||
|
if (first === lastFirst && last === lastLast) return;
|
||||||
|
lastFirst = first;
|
||||||
|
lastLast = last;
|
||||||
|
current.onChange(first, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(compute);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial measurement. Two rAFs because the first runs *during* the
|
||||||
|
// current frame's mount cycle — shells may not have computed layout
|
||||||
|
// yet, so `getBoundingClientRect` returns zeros. Bouncing once more
|
||||||
|
// lets the browser finish layout before we measure.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(compute));
|
||||||
|
|
||||||
|
node.addEventListener('scroll', schedule, { passive: true });
|
||||||
|
// Resize / content changes (new pages loaded, sidebar toggled,
|
||||||
|
// thumbnail size flipped) also shift the visible band — recompute.
|
||||||
|
const ro = new ResizeObserver(schedule);
|
||||||
|
ro.observe(node);
|
||||||
|
|
||||||
|
// No-op handle preserved so host code (`tileRegister`) doesn't need
|
||||||
|
// to change shape. Shell-scan reads geometry directly; per-tile
|
||||||
|
// registration isn't needed.
|
||||||
|
const handle: VisibleRangeHandle = {
|
||||||
|
register() {},
|
||||||
|
unregister() {}
|
||||||
|
};
|
||||||
|
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: VisibleRangeParams) {
|
||||||
|
current = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
node.removeEventListener('scroll', schedule);
|
||||||
|
ro.disconnect();
|
||||||
|
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle })
|
||||||
|
.__visibleRange;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the handle the action stashed on the scroll-root node. Kept for
|
||||||
|
* callers that still want the (now-no-op) register/unregister surface;
|
||||||
|
* new callers can ignore this entirely. */
|
||||||
|
export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null {
|
||||||
|
if (!node) return null;
|
||||||
|
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;
|
||||||
|
}
|
||||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
265
web/src/lib/components/duplicates/CrossFolderGroupCard.svelte
Normal file
265
web/src/lib/components/duplicates/CrossFolderGroupCard.svelte
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<!--
|
||||||
|
One cross-folder duplicate group rendered as a card. Lists every on-disk
|
||||||
|
copy of the same byte-identical file. The user picks one to keep; the
|
||||||
|
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
|
||||||
|
|
||||||
|
Differences from StackGroupCard (which operates on PhotoPrism Files in
|
||||||
|
a single Photo stack):
|
||||||
|
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
|
||||||
|
index time). They're files on disk only.
|
||||||
|
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
|
||||||
|
can render every copy from the same hash even though only one Photo
|
||||||
|
entry exists.
|
||||||
|
- Resolution moves files (reversible) rather than deletes (irreversible).
|
||||||
|
|
||||||
|
Same keyboard contract as StackGroupCard: arrows pick the keeper,
|
||||||
|
Enter commits.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
archiveDuplicatePaths,
|
||||||
|
type CrossFolderDuplicateGroup
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: CrossFolderDuplicateGroup;
|
||||||
|
/** First-card auto-focus, same pattern as StackGroupCard. */
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
let { group, autoFocus = false }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
let keep = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let sectionEl: HTMLElement | undefined = $state();
|
||||||
|
let gridEl: HTMLElement | undefined = $state();
|
||||||
|
let cols = $state(1);
|
||||||
|
|
||||||
|
// Seed `keep` from the indexed path when available; that's the safest
|
||||||
|
// default because losing it would leave PhotoPrism with no copy. Fall
|
||||||
|
// back to the first listed path.
|
||||||
|
$effect(() => {
|
||||||
|
const validPaths = new Set(group.files.map((f) => f.path));
|
||||||
|
if (!keep || !validPaths.has(keep)) {
|
||||||
|
keep =
|
||||||
|
group.indexedPath && validPaths.has(group.indexedPath)
|
||||||
|
? group.indexedPath
|
||||||
|
: group.files[0]?.path ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column-count tracking — identical pattern to StackGroupCard.
|
||||||
|
$effect(() => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const measure = () => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const n = getComputedStyle(gridEl)
|
||||||
|
.gridTemplateColumns.split(' ')
|
||||||
|
.filter(Boolean).length;
|
||||||
|
cols = Math.max(1, n);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(gridEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
void view.thumbnailSize;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const n = getComputedStyle(gridEl)
|
||||||
|
.gridTemplateColumns.split(' ')
|
||||||
|
.filter(Boolean).length;
|
||||||
|
cols = Math.max(1, n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sizeLabel(bytes: number): string {
|
||||||
|
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||||
|
return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortFolder(relPath: string): string {
|
||||||
|
const segs = relPath.split('/').filter(Boolean);
|
||||||
|
if (segs.length <= 1) return '(root)';
|
||||||
|
return segs.slice(0, -1).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveKeep(delta: number) {
|
||||||
|
const i = group.files.findIndex((f) => f.path === keep);
|
||||||
|
if (i < 0) return;
|
||||||
|
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
|
||||||
|
keep = group.files[next].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (busy) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
moveKeep(-1);
|
||||||
|
return;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
moveKeep(1);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
moveKeep(-cols);
|
||||||
|
return;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
moveKeep(cols);
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
void commit();
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
(e.target as HTMLElement)?.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (busy || group.files.length < 2) return;
|
||||||
|
// Defensive guard: never archive the indexed copy. The user can
|
||||||
|
// pick a different "keeper" but the archive list is computed AFTER
|
||||||
|
// resolving that into "everything except the keeper". If they pick
|
||||||
|
// a non-indexed copy as keeper, the indexed one gets archived —
|
||||||
|
// PhotoPrism will lose its photo entry on the cleanup reindex.
|
||||||
|
// That's a legitimate user choice (they wanted to move the
|
||||||
|
// canonical copy), just call it out in the toast.
|
||||||
|
const losers = group.files.filter((f) => f.path !== keep);
|
||||||
|
if (losers.length === 0) return;
|
||||||
|
const losingIndexed =
|
||||||
|
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
`Archived ${result.moved.length}; ${result.errors.length} failed`,
|
||||||
|
{
|
||||||
|
description: result.errors[0].error
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
|
||||||
|
{
|
||||||
|
description: losingIndexed
|
||||||
|
? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
|
||||||
|
: 'Files moved to .duplicates/ inside originals.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={sectionEl}
|
||||||
|
tabindex="0"
|
||||||
|
role="application"
|
||||||
|
aria-label={`Duplicate group · ${group.files.length} copies`}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
>
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground">
|
||||||
|
{group.files.length} copies · {sizeLabel(group.size)} each
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-[10px] font-mono text-muted-foreground">
|
||||||
|
sha1 {group.hash.slice(0, 16)}…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={busy || group.files.length < 2}
|
||||||
|
onclick={commit}
|
||||||
|
title="Move the unselected copies to .duplicates/ (reversible)"
|
||||||
|
>
|
||||||
|
Keep selected, archive rest
|
||||||
|
<kbd
|
||||||
|
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>Enter</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={gridEl}
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each group.files as file (file.path)}
|
||||||
|
{@const isKeep = file.path === keep}
|
||||||
|
{@const isIndexed = file.path === group.indexedPath}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (keep = file.path)}
|
||||||
|
class:scale-95={isKeep}
|
||||||
|
class:ring-2={isKeep}
|
||||||
|
class:ring-blue-500={isKeep}
|
||||||
|
class:ring-offset-2={isKeep}
|
||||||
|
class:ring-offset-background={isKeep}
|
||||||
|
class:transition-[transform,box-shadow]={isKeep}
|
||||||
|
class:duration-300={isKeep}
|
||||||
|
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
|
||||||
|
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-square w-full overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={thumbUrl(group.hash, 'tile_500')}
|
||||||
|
alt={file.path}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{#if isKeep}
|
||||||
|
<span
|
||||||
|
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
Keep
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if isIndexed}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
title="Currently in the library"
|
||||||
|
>
|
||||||
|
Indexed
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||||
|
title={file.path}
|
||||||
|
>
|
||||||
|
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
|
||||||
|
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
159
web/src/lib/components/duplicates/DuplicatesView.svelte
Normal file
159
web/src/lib/components/duplicates/DuplicatesView.svelte
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<!--
|
||||||
|
Duplicate-resolution page body. Two panels driven by the parent
|
||||||
|
route's `activeTab` prop (URL-bound):
|
||||||
|
|
||||||
|
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
|
||||||
|
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
|
||||||
|
`stack:true` and resolve via `setPrimary` + `deleteFile`.
|
||||||
|
|
||||||
|
2. Cross-folder — files PhotoPrism silently rejected at index time
|
||||||
|
because they were byte-identical to an existing entry. PhotoPrism
|
||||||
|
never adds those rows to its DB, so we scan the filesystem via the
|
||||||
|
mule-sidecar. Resolution moves the unwanted copies into a
|
||||||
|
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
|
||||||
|
|
||||||
|
The cross-folder scan auto-fires when its tab is active — with size
|
||||||
|
pre-filtering it stays fast (~250ms for 400 files in practice) and a
|
||||||
|
long staleTime keeps tab bounces from re-running it. The button is
|
||||||
|
kept for manual "rescan after I moved files" refreshes.
|
||||||
|
|
||||||
|
Tabs themselves render in the parent route's Toolbar so they line up
|
||||||
|
visually with the `/tags` pill row.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
scanCrossFolderDuplicates,
|
||||||
|
type CrossFolderScanResult
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||||
|
import StackGroupCard from './StackGroupCard.svelte';
|
||||||
|
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
|
||||||
|
|
||||||
|
type Tab = 'stacks' | 'cross-folder';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeTab: Tab;
|
||||||
|
groups: DuplicateGroup[];
|
||||||
|
pending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
let { activeTab, groups, pending, error }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// Cross-folder scan auto-fires when the tab is active. The 5-minute
|
||||||
|
// staleTime means a fresh visit reuses the prior result; the
|
||||||
|
// "Rescan filesystem" button invalidates to force a re-scan after
|
||||||
|
// the user has moved files around.
|
||||||
|
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||||
|
queryKey: ['duplicates-cross-folder'],
|
||||||
|
queryFn: scanCrossFolderDuplicates,
|
||||||
|
enabled: activeTab === 'cross-folder',
|
||||||
|
staleTime: 5 * 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
function rescan() {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (crossQuery.error) {
|
||||||
|
toast.error(
|
||||||
|
crossQuery.error instanceof Error
|
||||||
|
? crossQuery.error.message
|
||||||
|
: 'Duplicates scan failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Stacks tab ----------------------------------------------------- -->
|
||||||
|
{#if activeTab === 'stacks'}
|
||||||
|
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
||||||
|
{#if pending}
|
||||||
|
<InlineLoader label="Loading stacks…" />
|
||||||
|
{:else if error}
|
||||||
|
<EmptyState
|
||||||
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Could not load stacks"
|
||||||
|
description={error instanceof Error ? error.message : 'unknown error'}
|
||||||
|
/>
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<EmptyState icon={Copy} title="No stacks">
|
||||||
|
{#snippet descriptionSnippet()}
|
||||||
|
<p>
|
||||||
|
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||||
|
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
||||||
|
the Duplicates tab.
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each groups as group, i (group.photo.UID)}
|
||||||
|
<StackGroupCard {group} autoFocus={i === 0} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
||||||
|
{#if activeTab === 'cross-folder'}
|
||||||
|
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||||
|
<header class="flex items-baseline justify-between gap-3">
|
||||||
|
<p class="text-[11px] text-muted-foreground">
|
||||||
|
Byte-identical files the indexer dropped at index time. Found by scanning the
|
||||||
|
originals tree directly.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={crossQuery.isFetching}
|
||||||
|
onclick={rescan}
|
||||||
|
>
|
||||||
|
{#if crossQuery.isFetching}
|
||||||
|
Scanning…
|
||||||
|
{:else}
|
||||||
|
Rescan filesystem
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if crossQuery.isFetching && !crossQuery.data}
|
||||||
|
<InlineLoader label="Hashing files under originals…" />
|
||||||
|
{:else if crossQuery.isError}
|
||||||
|
<EmptyState
|
||||||
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Scan failed"
|
||||||
|
description={crossQuery.error instanceof Error
|
||||||
|
? crossQuery.error.message
|
||||||
|
: 'unknown error'}
|
||||||
|
/>
|
||||||
|
{:else if crossCount === 0}
|
||||||
|
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
||||||
|
{#snippet descriptionSnippet()}
|
||||||
|
{#if crossQuery.data}
|
||||||
|
<p class="text-[10px] text-muted-foreground/70">
|
||||||
|
scanned in {crossQuery.data.scannedMs} ms
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
||||||
|
<CrossFolderGroupCard {group} autoFocus={i === 0} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
289
web/src/lib/components/duplicates/StackGroupCard.svelte
Normal file
289
web/src/lib/components/duplicates/StackGroupCard.svelte
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<!--
|
||||||
|
One duplicate stack rendered as a card. Each variant file is a clickable
|
||||||
|
tile; clicking selects it as the candidate "best". Committing promotes
|
||||||
|
the selected file to Primary (via `setPrimary`) and deletes the rest from
|
||||||
|
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
|
||||||
|
files/:fid` route).
|
||||||
|
|
||||||
|
Why DELETE instead of unstack-then-archive (which the plan started with):
|
||||||
|
PhotoPrism's `/unstack` returns `only originals can be unstacked` for
|
||||||
|
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
|
||||||
|
pairs. DELETE works for all of them — and cascades through the live-
|
||||||
|
photo group automatically, so one click resolves the whole stack. The
|
||||||
|
on-disk file is renamed with a hash suffix (not erased), so a future
|
||||||
|
manual reindex can recover it if needed.
|
||||||
|
|
||||||
|
Keyboard:
|
||||||
|
- Section is tabindex=0; focusing it captures arrow keys + Enter.
|
||||||
|
- Left/Right move the "best" highlight one file; Up/Down move by the
|
||||||
|
grid's computed column count (same trick the timeline uses for
|
||||||
|
cross-row arrow nav).
|
||||||
|
- Enter commits the current selection. Esc removes focus from the card.
|
||||||
|
- The page's first card auto-focuses on mount so the user can drive
|
||||||
|
the workflow keyboard-first.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { deleteFile, setPrimary } from '$lib/services/photoprism';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: DuplicateGroup;
|
||||||
|
/** When true, the section auto-focuses on mount so the user can
|
||||||
|
* arrow-key/Enter the workflow without reaching for the mouse.
|
||||||
|
* Only the page's first card should get this. */
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
let { group, autoFocus = false }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
let best = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let sectionEl: HTMLElement | undefined = $state();
|
||||||
|
let gridEl: HTMLElement | undefined = $state();
|
||||||
|
let cols = $state(1);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Seed / re-seed `best` from the prop when the underlying group
|
||||||
|
// changes (keyed each + UID key normally keeps this stable, but
|
||||||
|
// the guard handles prop swaps without overwriting user clicks).
|
||||||
|
if (!best || !group.files.some((f) => f.UID === best)) {
|
||||||
|
best = group.bestFileUid;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the grid's column count via ResizeObserver — same approach
|
||||||
|
// the timeline uses. Reading `gridTemplateColumns` from computed
|
||||||
|
// style is O(1) regardless of how many tiles render.
|
||||||
|
$effect(() => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const measure = () => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const n = getComputedStyle(gridEl)
|
||||||
|
.gridTemplateColumns.split(' ')
|
||||||
|
.filter(Boolean).length;
|
||||||
|
cols = Math.max(1, n);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(gridEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
|
// thumbnailSize changes alter cols without resizing the grid; re-
|
||||||
|
// measure on the next microtask.
|
||||||
|
$effect(() => {
|
||||||
|
void view.thumbnailSize;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const n = getComputedStyle(gridEl)
|
||||||
|
.gridTemplateColumns.split(' ')
|
||||||
|
.filter(Boolean).length;
|
||||||
|
cols = Math.max(1, n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function shortPath(name: string): string {
|
||||||
|
const segs = name.split('/').filter(Boolean);
|
||||||
|
if (segs.length <= 2) return name;
|
||||||
|
return '…/' + segs.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dims(f: { Width?: number; Height?: number }): string {
|
||||||
|
if (!f.Width || !f.Height) return '';
|
||||||
|
return `${f.Width}×${f.Height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeLabel(bytes?: number): string {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||||
|
return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBest(delta: number) {
|
||||||
|
const i = group.files.findIndex((f) => f.UID === best);
|
||||||
|
if (i < 0) return;
|
||||||
|
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
|
||||||
|
best = group.files[next].UID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (busy) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
moveBest(-1);
|
||||||
|
return;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
moveBest(1);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
moveBest(-cols);
|
||||||
|
return;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
moveBest(cols);
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
void commit();
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
(e.target as HTMLElement)?.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (busy || group.files.length < 2) return;
|
||||||
|
busy = true;
|
||||||
|
const photoUid = group.photo.UID;
|
||||||
|
const losers = group.files.filter((f) => f.UID !== best);
|
||||||
|
try {
|
||||||
|
// 1. Promote the user's pick to Primary first (idempotent — if
|
||||||
|
// it's already Primary, the call is a no-op on the server).
|
||||||
|
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
|
||||||
|
if (best !== currentPrimary) {
|
||||||
|
await setPrimary(photoUid, best);
|
||||||
|
}
|
||||||
|
// 2. Delete each non-best file. PhotoPrism cascades through
|
||||||
|
// related variants in the same logical group (live-photo
|
||||||
|
// pairs, sidecar companions), so a single DELETE on one
|
||||||
|
// HEIC variant clears the whole HEIC+MOV pair in one go.
|
||||||
|
// Loop tolerates partial success — if PhotoPrism already
|
||||||
|
// cleared the file via cascade, the next DELETE 404s and
|
||||||
|
// we move on.
|
||||||
|
for (const f of losers) {
|
||||||
|
try {
|
||||||
|
await deleteFile(photoUid, f.UID);
|
||||||
|
} catch (err) {
|
||||||
|
// 404 means the file's already gone (cascade) — fine.
|
||||||
|
// Any other status means we have a real problem; bubble it.
|
||||||
|
const status = (err as { response?: { status?: number } })?.response
|
||||||
|
?.status;
|
||||||
|
if (status !== 404) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`Resolved · kept 1 of ${group.files.length}`);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['duplicates'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error && err.message ? err.message : 'Resolve failed';
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
|
||||||
|
none` because we paint our own focus ring on .focus-visible below
|
||||||
|
(otherwise the browser default outline would clash with the tile
|
||||||
|
selection ring). -->
|
||||||
|
<!--
|
||||||
|
`role="application"` declares this as a custom keyboard widget (arrow
|
||||||
|
keys + Enter, not standard reading order). The element below is a
|
||||||
|
`<div>` rather than `<section>` because Svelte's a11y linter treats
|
||||||
|
`<section>` as strictly non-interactive even with an explicit
|
||||||
|
application role.
|
||||||
|
-->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={sectionEl}
|
||||||
|
tabindex="0"
|
||||||
|
role="application"
|
||||||
|
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
>
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground">
|
||||||
|
{group.files.length} files in this stack
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-xs text-muted-foreground">
|
||||||
|
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={busy || group.files.length < 2}
|
||||||
|
onclick={commit}
|
||||||
|
title="Promote the selected file and delete the rest from this stack"
|
||||||
|
>
|
||||||
|
Keep selected, delete rest
|
||||||
|
<kbd
|
||||||
|
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>Enter</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={gridEl}
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each group.files as file (file.UID)}
|
||||||
|
{@const isBest = file.UID === best}
|
||||||
|
{@const sizeStr = sizeLabel(file.Size)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (best = file.UID)}
|
||||||
|
class:scale-95={isBest}
|
||||||
|
class:ring-2={isBest}
|
||||||
|
class:ring-blue-500={isBest}
|
||||||
|
class:ring-offset-2={isBest}
|
||||||
|
class:ring-offset-background={isBest}
|
||||||
|
class:transition-[transform,box-shadow]={isBest}
|
||||||
|
class:duration-300={isBest}
|
||||||
|
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
|
||||||
|
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-square w-full overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={thumbUrl(file.Hash, 'tile_500')}
|
||||||
|
alt={file.Name}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{#if isBest}
|
||||||
|
<span
|
||||||
|
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
Best
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if dims(file)}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||||
|
>
|
||||||
|
{dims(file)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||||
|
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||||
|
>
|
||||||
|
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
|
||||||
|
{#if sizeStr}
|
||||||
|
<div>{sizeStr}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!--
|
||||||
|
Shared empty / no-data placeholder. Doubles as an error display when
|
||||||
|
`tone="destructive"` (swaps colors and announces with role=alert).
|
||||||
|
Use `size="compact"` inside sidebars where vertical space is tight.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon?: Component<any> | any;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
descriptionSnippet?: Snippet;
|
||||||
|
align?: 'left' | 'center';
|
||||||
|
tone?: 'muted' | 'destructive';
|
||||||
|
size?: 'compact' | 'default';
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
descriptionSnippet,
|
||||||
|
align,
|
||||||
|
tone = 'muted',
|
||||||
|
size = 'default',
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
|
||||||
|
const isDestructive = $derived(tone === 'destructive');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if size === 'compact'}
|
||||||
|
<div
|
||||||
|
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
|
||||||
|
? 'items-center justify-center text-center'
|
||||||
|
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
|
||||||
|
role={isDestructive ? 'alert' : undefined}
|
||||||
|
aria-live={isDestructive ? 'assertive' : undefined}
|
||||||
|
>
|
||||||
|
{#if Icon}
|
||||||
|
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span>{title}</span>
|
||||||
|
{#if descriptionSnippet}
|
||||||
|
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
|
||||||
|
{:else if description}
|
||||||
|
<div class="mt-0.5 opacity-80">{description}</div>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
<div class="mt-1.5">{@render children()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
|
||||||
|
? 'items-center text-center'
|
||||||
|
: 'items-start text-left'}"
|
||||||
|
role={isDestructive ? 'alert' : undefined}
|
||||||
|
aria-live={isDestructive ? 'assertive' : undefined}
|
||||||
|
>
|
||||||
|
{#if Icon}
|
||||||
|
<Icon
|
||||||
|
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
|
||||||
|
{#if descriptionSnippet}
|
||||||
|
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
|
||||||
|
{@render descriptionSnippet()}
|
||||||
|
</div>
|
||||||
|
{:else if description}
|
||||||
|
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
<div class="mt-2">{@render children()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!--
|
||||||
|
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
|
||||||
|
in sidebars, popovers, and right rails. For the initial photo-grid load,
|
||||||
|
use SkeletonGrid instead (layout-preserving).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
align?: 'left' | 'center';
|
||||||
|
srOnly?: boolean;
|
||||||
|
polite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label = 'Loading…',
|
||||||
|
size = 'default',
|
||||||
|
align = 'left',
|
||||||
|
srOnly = false,
|
||||||
|
polite = true
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
|
||||||
|
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
|
||||||
|
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
|
||||||
|
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-live={polite ? 'polite' : 'off'}
|
||||||
|
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
|
||||||
|
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
|
||||||
|
</p>
|
||||||
2
web/src/lib/components/feedback/index.ts
Normal file
2
web/src/lib/components/feedback/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as EmptyState } from './EmptyState.svelte';
|
||||||
|
export { default as InlineLoader } from './InlineLoader.svelte';
|
||||||
249
web/src/lib/components/layout/FolderTree.svelte
Normal file
249
web/src/lib/components/layout/FolderTree.svelte
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
/**
|
||||||
|
* Build a nested folder tree from PhotoPrism's flat `Path`-keyed
|
||||||
|
* folder list. The API returns one row per subfolder
|
||||||
|
* (`2024`, `2024/lyon`, `2024/paris`, …); we group by the parent
|
||||||
|
* segment so the UI can render a real <ul> tree.
|
||||||
|
*/
|
||||||
|
export interface TreeNode {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
children: TreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree(paths: string[]): TreeNode[] {
|
||||||
|
const root: TreeNode = { path: '', name: '', children: [] };
|
||||||
|
const index = new Map<string, TreeNode>([['', root]]);
|
||||||
|
const sorted = [...paths].sort();
|
||||||
|
for (const p of sorted) {
|
||||||
|
const parts = p.split('/');
|
||||||
|
let parentPath = '';
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const here = parts.slice(0, i + 1).join('/');
|
||||||
|
if (!index.has(here)) {
|
||||||
|
const node: TreeNode = {
|
||||||
|
path: here,
|
||||||
|
name: parts[i],
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
const parent = index.get(parentPath);
|
||||||
|
if (parent) parent.children.push(node);
|
||||||
|
index.set(here, node);
|
||||||
|
}
|
||||||
|
parentPath = here;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root.children;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||||
|
import Self from './FolderTree.svelte';
|
||||||
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nodes: TreeNode[];
|
||||||
|
depth?: number;
|
||||||
|
onPick: (path: string) => void;
|
||||||
|
/** Mutating callbacks are only required when readonly !== true. The
|
||||||
|
* picker (HeapConvertDialog) reuses the tree just for `onPick`. */
|
||||||
|
onRename?: (path: string) => void;
|
||||||
|
onDelete?: (path: string) => void;
|
||||||
|
onCreateChild?: (parent: string) => void;
|
||||||
|
/** Read-only mode: hides the kebab menu and disables double-click
|
||||||
|
* rename, so the tree can be reused as a folder picker. */
|
||||||
|
readonly?: boolean;
|
||||||
|
/** Override the active-row predicate. By default rows light up when
|
||||||
|
* `filters.folderPath` matches (the sidebar nav case); the picker
|
||||||
|
* passes its own selection so the dialog has independent state. */
|
||||||
|
selectedPath?: string | null;
|
||||||
|
/** Optional per-path photo count. When provided, each row renders a
|
||||||
|
* compact badge with the count — matching the heaps section's
|
||||||
|
* "{n} photos" affordance. Undefined keeps the badge off entirely
|
||||||
|
* (the picker dialog doesn't need it). */
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
nodes,
|
||||||
|
depth = 0,
|
||||||
|
onPick,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onCreateChild,
|
||||||
|
readonly = false,
|
||||||
|
selectedPath,
|
||||||
|
counts
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Auto-expanded folders, persisted to localStorage so the tree state
|
||||||
|
// survives reloads. Empty set = everything collapsed at start.
|
||||||
|
const KEY = 'mule_folder_open';
|
||||||
|
let openSet = $state<Set<string>>(loadOpen());
|
||||||
|
function loadOpen(): Set<string> {
|
||||||
|
if (!browser) return new Set();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function persist() {
|
||||||
|
if (browser) localStorage.setItem(KEY, JSON.stringify([...openSet]));
|
||||||
|
}
|
||||||
|
function toggle(p: string) {
|
||||||
|
if (openSet.has(p)) openSet.delete(p);
|
||||||
|
else openSet.add(p);
|
||||||
|
openSet = new Set(openSet); // re-trigger reactivity
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(path: string): boolean {
|
||||||
|
if (selectedPath !== undefined) return selectedPath === path;
|
||||||
|
return filters.folderPath === path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-expand the ancestor chain of the active folder so the
|
||||||
|
// highlighted row is actually visible after a deep-link navigation
|
||||||
|
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
|
||||||
|
// FolderTree instance only owns the openSet entries for the nodes
|
||||||
|
// rendered at its depth, but since the root instance expands the
|
||||||
|
// top-level ancestor first, the child instance for that subtree is
|
||||||
|
// then mounted and runs the same effect — the cascade naturally
|
||||||
|
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
|
||||||
|
// picker has its own selectedPath and shouldn't drive the sidebar
|
||||||
|
// state). Skipped for top-level paths (nothing to expand).
|
||||||
|
$effect(() => {
|
||||||
|
if (readonly || !browser) return;
|
||||||
|
const fp = selectedPath ?? filters.folderPath;
|
||||||
|
if (!fp || fp === '/' || !fp.includes('/')) return;
|
||||||
|
untrack(() => {
|
||||||
|
const parts = fp.split('/');
|
||||||
|
let changed = false;
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const ancestor = parts.slice(0, i).join('/');
|
||||||
|
if (ancestor && !openSet.has(ancestor)) {
|
||||||
|
openSet.add(ancestor);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
openSet = new Set(openSet);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each nodes as node (node.path)}
|
||||||
|
{@const open = openSet.has(node.path)}
|
||||||
|
{@const active = isActive(node.path)}
|
||||||
|
{@const hasChildren = node.children.length > 0}
|
||||||
|
<li>
|
||||||
|
<!--
|
||||||
|
Indent via padding-left rather than nested margin+border, so the
|
||||||
|
active row's background bleeds edge-to-edge of the sidebar (matches
|
||||||
|
mule-image's compact tree). 8px baseline aligns the depth-0 chevron
|
||||||
|
with the px-2 of Views/Heaps rows; +12px per nested level.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
style="padding-left: {4 + depth * 12}px;"
|
||||||
|
>
|
||||||
|
{#if hasChildren}
|
||||||
|
<button
|
||||||
|
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
||||||
|
class:text-muted-foreground={!active}
|
||||||
|
onclick={() => toggle(node.path)}
|
||||||
|
title={open ? 'Collapse' : 'Expand'}
|
||||||
|
aria-label={open ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{open ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<!-- Spacer keeps childless siblings aligned with their chevroned
|
||||||
|
peers at every depth, so labels share a common left edge
|
||||||
|
across the sidebar (folders, heaps, views, manage). -->
|
||||||
|
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<!--
|
||||||
|
Count badge lives INSIDE the button so the entire row (label
|
||||||
|
+ badge) is one hit target — the badge was previously a dead
|
||||||
|
zone right where the user's eye lands.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||||
|
onclick={() => onPick(node.path)}
|
||||||
|
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||||
|
title={node.path}
|
||||||
|
>
|
||||||
|
<span class="truncate">{node.name}</span>
|
||||||
|
{#if counts && counts[node.path] !== undefined}
|
||||||
|
{@const n = counts[node.path]}
|
||||||
|
<span
|
||||||
|
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||||
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{n >= 1000 ? '1000+' : n}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if !readonly}
|
||||||
|
<!-- Hover-revealed kebab. `display: none` until row hover
|
||||||
|
(or while the menu is open via has-[[data-state=open]])
|
||||||
|
so the count holds the row's right edge by default
|
||||||
|
and the kebab pushes it left when it appears.
|
||||||
|
Suppressed in readonly mode (picker). -->
|
||||||
|
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||||
|
<KebabMenu label="Folder actions">
|
||||||
|
<Item
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||||
|
onSelect={() => onCreateChild?.(node.path)}
|
||||||
|
>
|
||||||
|
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
New subfolder
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||||
|
onSelect={() => onRename?.(node.path)}
|
||||||
|
>
|
||||||
|
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Rename
|
||||||
|
</Item>
|
||||||
|
<Separator class="my-1 h-px bg-border" />
|
||||||
|
<Item
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] text-destructive outline-none hover:bg-destructive/10 focus:bg-destructive/10"
|
||||||
|
onSelect={() => onDelete?.(node.path)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
Delete folder…
|
||||||
|
</Item>
|
||||||
|
</KebabMenu>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if hasChildren && open}
|
||||||
|
<Self
|
||||||
|
nodes={node.children}
|
||||||
|
depth={depth + 1}
|
||||||
|
{onPick}
|
||||||
|
{onRename}
|
||||||
|
{onDelete}
|
||||||
|
{onCreateChild}
|
||||||
|
{readonly}
|
||||||
|
{selectedPath}
|
||||||
|
{counts}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user