Compare commits
317 Commits
46a0d7aba8
...
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 | |||
| 78e12e8309 | |||
| 6d1b227fb9 |
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
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -60,9 +61,18 @@ build/
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# PhotoPrism state (sidecars, cache, thumbs, db backups) — regenerable.
|
||||
/pp/storage/
|
||||
/pp/import/
|
||||
|
||||
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
||||
/sidecar/data/
|
||||
|
||||
# Sidecar Go build output.
|
||||
/sidecar/mule-sidecar
|
||||
|
||||
# Photos (for development)
|
||||
/photos/
|
||||
|
||||
# Thumbnails
|
||||
/thumbs/
|
||||
/trash/
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
|
||||
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
|
||||
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and search
|
||||
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
|
||||
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
|
||||
- **Heaps**: Temporary collections for organizing photos
|
||||
- **Tags & Ratings**: Organize with tags, star ratings, and color labels
|
||||
- **Dark Mode**: Photography-optimized dark interface
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Python 3.12 with FastAPI
|
||||
- SQLite with SQLAlchemy (async)
|
||||
- Celery + Redis for background tasks
|
||||
- pyvips for fast thumbnail generation
|
||||
- ExifTool for metadata extraction
|
||||
|
||||
### Frontend
|
||||
- React 18 with TypeScript
|
||||
- Vite for fast development
|
||||
- TanStack Query for data fetching
|
||||
- TanStack Virtual for virtualized scrolling
|
||||
- Tailwind CSS for styling
|
||||
- Zustand for state management
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- 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`
|
||||
Self-hosted photo management built on top of [PhotoPrism][pp]. A SvelteKit
|
||||
frontend ([`web/`](web/)) plus a small Go service ([`sidecar/`](sidecar/))
|
||||
fill in the keyboard-driven UI and the file/folder/mark endpoints
|
||||
PhotoPrism's REST API does not expose. PhotoPrism itself handles
|
||||
indexing, originals, thumbnails, and the database; we never re-implement
|
||||
those.
|
||||
|
||||
## Architecture
|
||||
|
||||
The application consists of 5 Docker services:
|
||||
|
||||
- **frontend**: React SPA served by Nginx
|
||||
- **backend**: FastAPI REST API
|
||||
- **worker**: Celery workers for background tasks
|
||||
- **redis**: Message broker for Celery
|
||||
- **db**: SQLite database (file-based)
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` `→` `↑` `↓` | Navigate photos |
|
||||
| `Space` | Quick preview |
|
||||
| `Enter` | Open loupe view |
|
||||
| `P` | Pick photo |
|
||||
| `X` | Reject photo |
|
||||
| `1-5` | Set star rating |
|
||||
| `Tab` | Toggle left sidebar |
|
||||
| `I` | Toggle metadata panel |
|
||||
| `G` | Grid view |
|
||||
| `E` | Loupe view |
|
||||
| `Delete` | Move to trash |
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```text
|
||||
┌──────────────────┐ /api/v1/* ┌──────────────┐
|
||||
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
|
||||
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
|
||||
│ │ ─────────┐ └──────────────┘
|
||||
└──────────────────┘ ▼
|
||||
┌──────────────┐
|
||||
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
|
||||
│ :8000 │ ──▶ originals FS (rename / folders / dups)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
Three compose services — `mariadb`, `photoprism`, `sidecar` — plus the
|
||||
SvelteKit `web/` app served separately. PhotoPrism's port `2342` is
|
||||
**bound to `127.0.0.1` only**; it isn't a user-facing surface. The
|
||||
SvelteKit app is.
|
||||
|
||||
What the sidecar adds on top of PhotoPrism (full list in
|
||||
[`sidecar/README.md`](sidecar/README.md)):
|
||||
|
||||
- Per-photo marks (rating + color) persisted to `mule_sidecar.marks`
|
||||
- File rename + folder create/rename/delete with PhotoPrism reindex
|
||||
- Heap (album) → folder conversion
|
||||
- Perceptual-hash duplicate scan + archive
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
# edit .env: set PHOTO_DIRS to the host path holding your library
|
||||
# rotate PP_ADMIN_PASSWORD, PP_DB_PASSWORD, PP_DB_ROOT_PASSWORD
|
||||
# before any non-local deployment.
|
||||
|
||||
podman-compose --env-file .env \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.podman.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Then serve the frontend. For local use the simplest path is the Vite
|
||||
dev server:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
# open http://localhost:5173
|
||||
```
|
||||
|
||||
For a static deployment, `npm run build` produces a bundle under
|
||||
`web/build/` that any static file host (nginx, Caddy, GitHub Pages-style)
|
||||
can serve. Reverse-proxy `/api/v1/*` to `http://127.0.0.1:2342` and
|
||||
`/api/sidecar/*` to `http://127.0.0.1:8000`.
|
||||
|
||||
PhotoPrism's own UI is still reachable from the host at
|
||||
`http://127.0.0.1:2342` if you need admin features (user management,
|
||||
settings) — set up an SSH tunnel from your laptop if the server is
|
||||
remote.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `mulita.yml` to configure:
|
||||
- Source photo directories
|
||||
- Thumbnail sizes and quality
|
||||
- Scanner settings
|
||||
- Performance tuning
|
||||
All knobs live in [`.env.example`](.env.example). The required ones:
|
||||
|
||||
## 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
|
||||
- Virtual scrolling for smooth timeline navigation
|
||||
- Thumbnail generation at 10+ photos/second
|
||||
- SQLite FTS5 for fast full-text search
|
||||
Sidecar-specific env (DB DSN, `USER_BASEPATHS`, etc.) is documented in
|
||||
[`sidecar/README.md`](sidecar/README.md).
|
||||
|
||||
## Future Features (Phase 2)
|
||||
## Read-only libraries
|
||||
|
||||
- AI-powered scene classification
|
||||
- Face detection and clustering
|
||||
- Smart albums
|
||||
- Duplicate detection
|
||||
- Export presets
|
||||
- Multi-user support
|
||||
The default originals mount is `:rw` because file operations (rename,
|
||||
folder mutations, duplicate archive, heap convert) need to mutate the
|
||||
filesystem. To run against a read-only archive, set
|
||||
`PP_ORIGINALS_MODE=ro` in `.env`. Browsing, marks, ratings, and color
|
||||
labels still work; the following sidecar endpoints return an OS error:
|
||||
|
||||
## 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,82 +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
|
||||
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
|
||||
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,
|
||||
connect_args={
|
||||
"check_same_thread": False, # SQLite specific
|
||||
"timeout": 30
|
||||
} if "sqlite" in settings.database_url else {}
|
||||
)
|
||||
|
||||
# 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("PRAGMA journal_mode=WAL")
|
||||
await conn.execute("PRAGMA synchronous=NORMAL")
|
||||
await conn.execute("PRAGMA cache_size=10000")
|
||||
await conn.execute("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("""
|
||||
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,33 +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
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Folder, SourceRoot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("")
|
||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||
"""Get folder tree"""
|
||||
result = await db.execute(select(Folder))
|
||||
folders = result.scalars().all()
|
||||
return folders
|
||||
|
||||
@router.post("/{folder_id}/scan")
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Trigger manual re-scan of folder"""
|
||||
from app.tasks.scan import scan_folder as scan_task
|
||||
|
||||
result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = result.scalar_one_or_none()
|
||||
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
scan_task.delay(folder.path)
|
||||
return {"status": "success", "message": f"Scan queued for {folder.path}"}
|
||||
@@ -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,61 +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():
|
||||
"""Get current scan status"""
|
||||
# This would connect to Celery to get task status
|
||||
# For now, return a simple response
|
||||
return {
|
||||
"status": "idle",
|
||||
"progress": 0,
|
||||
"current_folder": None,
|
||||
"queued": 0,
|
||||
"done": 0
|
||||
}
|
||||
@@ -1,310 +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
|
||||
|
||||
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")
|
||||
|
||||
thumb_path = getattr(photo, f'thumb_{size}')
|
||||
|
||||
if not thumb_path or not os.path.exists(thumb_path):
|
||||
raise HTTPException(status_code=404, detail="Thumbnail not found")
|
||||
|
||||
# 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]] = []
|
||||
|
||||
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,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,277 +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
|
||||
import pyvips
|
||||
import rawpy
|
||||
import imageio
|
||||
from PIL import Image
|
||||
from pillow_heif import register_heif_opener
|
||||
import ffmpeg
|
||||
|
||||
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) -> pyvips.Image:
|
||||
"""Process standard image formats (JPEG, PNG, etc.)"""
|
||||
return pyvips.Image.new_from_file(filepath, access='sequential')
|
||||
|
||||
def process_raw_image(filepath: str) -> pyvips.Image:
|
||||
"""Process RAW image formats"""
|
||||
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 pyvips image
|
||||
return pyvips.Image.new_from_array(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)
|
||||
|
||||
def extract_raw_preview(filepath: str) -> Optional[pyvips.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 pyvips.Image.new_from_file(tmp.name, access='sequential')
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def process_heic_image(filepath: str) -> pyvips.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')
|
||||
# Save to temp file and load with pyvips
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
||||
img.save(tmp.name, 'JPEG')
|
||||
return pyvips.Image.new_from_file(tmp.name, access='sequential')
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing HEIC file {filepath}: {e}")
|
||||
raise
|
||||
|
||||
def process_video_thumbnail(filepath: str) -> pyvips.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 pyvips.Image.new_from_file(tmp.name, access='sequential')
|
||||
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) -> pyvips.Image:
|
||||
"""Create a placeholder thumbnail for failed processing"""
|
||||
# Create a simple gray placeholder
|
||||
placeholder = pyvips.Image.black(640, 480)
|
||||
placeholder = placeholder + [128, 128, 128] # Make it gray
|
||||
return placeholder
|
||||
|
||||
def auto_rotate_image(image: pyvips.Image) -> pyvips.Image:
|
||||
"""Auto-rotate image based on EXIF orientation"""
|
||||
try:
|
||||
orientation = image.get('orientation')
|
||||
|
||||
rotation_map = {
|
||||
3: 180,
|
||||
6: 90,
|
||||
8: 270
|
||||
}
|
||||
|
||||
if orientation in rotation_map:
|
||||
image = image.rot(rotation_map[orientation])
|
||||
except:
|
||||
pass # No orientation data available
|
||||
|
||||
return image
|
||||
|
||||
def generate_thumbnail(image: pyvips.Image, size: int, output_path: str):
|
||||
"""Generate a thumbnail of the specified size"""
|
||||
# Calculate scale to fit within size (longest edge)
|
||||
width = image.width
|
||||
height = image.height
|
||||
|
||||
if width > height:
|
||||
scale = size / width
|
||||
else:
|
||||
scale = size / height
|
||||
|
||||
# Only downscale, never upscale
|
||||
if scale < 1:
|
||||
image = image.resize(scale)
|
||||
|
||||
# Save as WebP with specified quality
|
||||
image.webpsave(
|
||||
output_path,
|
||||
Q=settings.thumbnails.quality,
|
||||
effort=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,50 +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.2
|
||||
rawpy==0.19.0
|
||||
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
|
||||
|
||||
# Hashing and security
|
||||
hashlib
|
||||
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,88 +1,209 @@
|
||||
version: '3.8'
|
||||
# Compose stack for the PhotoPrism-backed photo app: mariadb + photoprism +
|
||||
# Go sidecar. The SvelteKit web/ frontend runs separately (Vite in dev,
|
||||
# static build in prod) and proxies /api/v1/* to photoprism and
|
||||
# /api/sidecar/* to the sidecar.
|
||||
#
|
||||
# podman-compose --env-file .env \
|
||||
# -f docker-compose.yml -f docker-compose.podman.yml up -d
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- mulita-network
|
||||
mariadb:
|
||||
# Fully-qualified for podman (which refuses short names by default).
|
||||
# Docker resolves the same digest.
|
||||
image: docker.io/library/mariadb:11
|
||||
container_name: pp-mariadb
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
- thumbs_data:/data/thumbs
|
||||
- db_data:/data/db
|
||||
- trash_data:/data/trash
|
||||
command:
|
||||
- --innodb-buffer-pool-size=512M
|
||||
- --transaction-isolation=READ-COMMITTED
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --max-connections=512
|
||||
- --innodb-rollback-on-timeout=OFF
|
||||
- --innodb-lock-wait-timeout=120
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
depends_on:
|
||||
- redis
|
||||
networks:
|
||||
- mulita-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-worker
|
||||
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
- 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
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
MARIADB_INITDB_SKIP_TZINFO: "1"
|
||||
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
|
||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env}
|
||||
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env}
|
||||
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||
# name on the photoprism-network bridge.
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- mulita-network
|
||||
- pp_mariadb_data:/var/lib/mysql
|
||||
# The init script creates the mule_sidecar database + user that the Go
|
||||
# sidecar service will use in M4. Idempotent; no-op on subsequent boots.
|
||||
# ":Z" is the SELinux private-relabel flag — needed on Fedora/RHEL hosts,
|
||||
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
|
||||
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD", "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
|
||||
command: redis-server --appendonly yes
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
# PhotoPrism's container drops to a non-root user via PHOTOPRISM_UID /
|
||||
# PHOTOPRISM_GID. Match the host user that owns ${PHOTO_DIRS} so the
|
||||
# process can read originals (and later write sidecars).
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
- apparmor:unconfined
|
||||
ports:
|
||||
# Loopback only — the SvelteKit web/ app (Vite dev or built bundle)
|
||||
# is the user-facing surface; PhotoPrism's own UI stays off the
|
||||
# public interface. Vite proxies /api/v1/* here, and the host-mode
|
||||
# sidecar reaches PHOTOPRISM_BASE_URL=http://localhost:2342. Admin
|
||||
# access to PP's UI is via SSH tunnel only.
|
||||
- "127.0.0.1:${PP_PORT:-2342}:2342"
|
||||
environment:
|
||||
PHOTOPRISM_ADMIN_USER: ${PP_ADMIN_USER:-admin}
|
||||
PHOTOPRISM_ADMIN_PASSWORD: ${PP_ADMIN_PASSWORD:?set PP_ADMIN_PASSWORD in .env}
|
||||
PHOTOPRISM_AUTH_MODE: ${PP_AUTH_MODE:-password}
|
||||
PHOTOPRISM_SITE_URL: ${PP_SITE_URL:-http://localhost:2342/}
|
||||
PHOTOPRISM_ORIGINALS_LIMIT: ${PP_ORIGINALS_LIMIT:-50000}
|
||||
PHOTOPRISM_HTTP_COMPRESSION: gzip
|
||||
PHOTOPRISM_LOG_LEVEL: ${PP_LOG_LEVEL:-info}
|
||||
# Indexer concurrency. Defaults to NumCPU/2 (= 3 on a 6-core LXC),
|
||||
# but each worker forks TF + ffmpeg + libvips so effective load is
|
||||
# much higher — a fresh index of 1.2k photos on M0 pushed the LXC
|
||||
# load to 50+ and starved sibling containers. Pin to a low value
|
||||
# for shared hosts; raise on dedicated machines.
|
||||
PHOTOPRISM_WORKERS: ${PP_WORKERS:-2}
|
||||
# podman-compose doesn't expand nested ${A:-${B:-…}}, so keep this
|
||||
# one-level. Override both PP_WORKERS and PP_INDEX_WORKERS if you
|
||||
# want them to differ.
|
||||
PHOTOPRISM_INDEX_WORKERS: ${PP_INDEX_WORKERS:-2}
|
||||
# M0 safety: keep originals read-only. Flip to "false" in M2 when the
|
||||
# right-sidebar enables metadata edits and we want EXIF backwrite.
|
||||
PHOTOPRISM_READONLY: ${PP_READONLY:-true}
|
||||
PHOTOPRISM_EXPERIMENTAL: "false"
|
||||
PHOTOPRISM_DISABLE_CHOWN: "true"
|
||||
PHOTOPRISM_DISABLE_WEBDAV: ${PP_DISABLE_WEBDAV:-false}
|
||||
PHOTOPRISM_DISABLE_SETTINGS: "false"
|
||||
PHOTOPRISM_DISABLE_TLS: "true"
|
||||
PHOTOPRISM_DEFAULT_TLS: "false"
|
||||
# AI/vision pipeline back on — per plan we re-introduce TF labels + faces.
|
||||
PHOTOPRISM_TENSORFLOW_OFF: "false"
|
||||
PHOTOPRISM_DETECT_NSFW: "true"
|
||||
PHOTOPRISM_UPLOAD_NSFW: "true"
|
||||
# Database
|
||||
PHOTOPRISM_DATABASE_DRIVER: mysql
|
||||
PHOTOPRISM_DATABASE_SERVER: mariadb:3306
|
||||
PHOTOPRISM_DATABASE_NAME: ${PP_DB_NAME:-photoprism}
|
||||
PHOTOPRISM_DATABASE_USER: ${PP_DB_USER:-photoprism}
|
||||
PHOTOPRISM_DATABASE_PASSWORD: ${PP_DB_PASSWORD}
|
||||
# Sidecars next to originals — read by the migrator at M5.
|
||||
PHOTOPRISM_SIDECAR_PATH: ""
|
||||
PHOTOPRISM_SIDECAR_YAML: "true"
|
||||
# EXIF backwrite — disabled in M0 (READONLY blocks writes anyway).
|
||||
# Override in .env: PP_BACKUP_DATABASE=true.
|
||||
PHOTOPRISM_DISABLE_BACKUPS: "false"
|
||||
PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true}
|
||||
PHOTOPRISM_DISABLE_EXIFTOOL: "false"
|
||||
# OIDC — set in .env when the IdP (Authentik) is wired up.
|
||||
# Empty values keep OIDC dormant; the username/password login still works.
|
||||
# PhotoPrism's CLI flags are --oidc-uri / --oidc-client / --oidc-secret
|
||||
# / --oidc-provider, so the env-var names it actually reads are
|
||||
# PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER (NOT _ISSUER_URL
|
||||
# / _CLIENT_ID / _CLIENT_SECRET / _PROVIDER_NAME — those are silently
|
||||
# ignored, OIDC stays dormant, and `photoprism show config` reports
|
||||
# blank oidc-uri / oidc-client). PHOTOPRISM_OIDC_REDIRECT is a bool
|
||||
# (auto-redirect-from-/library/login), not a URL — PhotoPrism builds
|
||||
# the callback from PHOTOPRISM_SITE_URL.
|
||||
PHOTOPRISM_OIDC_PROVIDER: ${OIDC_PROVIDER_NAME:-${OIDC_PROVIDER:-}}
|
||||
PHOTOPRISM_OIDC_URI: ${OIDC_ISSUER_URL:-${OIDC_URI:-}}
|
||||
PHOTOPRISM_OIDC_CLIENT: ${OIDC_CLIENT_ID:-${OIDC_CLIENT:-}}
|
||||
PHOTOPRISM_OIDC_SECRET: ${OIDC_CLIENT_SECRET:-${OIDC_SECRET:-}}
|
||||
PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||
PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true}
|
||||
PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user}
|
||||
PHOTOPRISM_OIDC_REDIRECT: ${OIDC_REDIRECT:-false}
|
||||
working_dir: /photoprism
|
||||
volumes:
|
||||
# Existing photo library — mounted read-only in M0; flip to :rw in M2
|
||||
# when the right-sidebar starts saving edits. ",Z" relabels for SELinux
|
||||
# on Fedora/RHEL; silent no-op elsewhere.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z"
|
||||
- "./pp/storage:/photoprism/storage:Z"
|
||||
- "./pp/import:/photoprism/import:Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
|
||||
# does not expose (file rename, folder mutations, heap convert, duplicate
|
||||
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
|
||||
# the SvelteKit dev server proxies /api/sidecar/* here.
|
||||
sidecar:
|
||||
build:
|
||||
context: ./sidecar
|
||||
container_name: pp-sidecar
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
photoprism:
|
||||
condition: service_started
|
||||
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
|
||||
# ownership the indexer expects on the bind-mounted originals.
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
ports:
|
||||
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
|
||||
# Behind a reverse proxy in production; never published beyond the
|
||||
# host.
|
||||
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
|
||||
environment:
|
||||
ORIGINALS_ROOT: /photoprism/originals
|
||||
PHOTOPRISM_BASE_URL: http://photoprism:2342
|
||||
# Bind on all interfaces inside the container so the host-side
|
||||
# 127.0.0.1:8000 port mapping can reach the listener. The Go
|
||||
# binary defaults to 127.0.0.1 for the host-mode dev loop.
|
||||
SIDECAR_LISTEN_ADDR: 0.0.0.0
|
||||
SIDECAR_PORT: "8000"
|
||||
SIDECAR_DB_HOST: mariadb
|
||||
SIDECAR_DB_PORT: "3306"
|
||||
SIDECAR_DB_USER: sidecar
|
||||
# Rotate before any non-local deployment. Provisioned by
|
||||
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
||||
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
||||
SIDECAR_DB_NAME: mule_sidecar
|
||||
# Second DB connection for poking PhotoPrism's own schema (only
|
||||
# used by the user-basepath reconciler today). Stays inert if
|
||||
# PP_DB_PASSWORD is empty — the reconciler then silently no-ops.
|
||||
PP_DB_HOST: mariadb
|
||||
PP_DB_PORT: "3306"
|
||||
PP_DB_USER: ${PP_DB_USER:-photoprism}
|
||||
PP_DB_PASSWORD: ${PP_DB_PASSWORD:-}
|
||||
PP_DB_NAME: ${PP_DB_NAME:-photoprism}
|
||||
# Declarative username → originals-relative BasePath mapping.
|
||||
# Format: comma-separated `user:path` pairs. Sidecar applies it
|
||||
# to auth_users on boot and every 60s, and `mkdir -p`s each
|
||||
# target subdirectory so PhotoPrism's ACL filter has somewhere to
|
||||
# point. Leave empty to disable.
|
||||
# USER_BASEPATHS="test:test, alice:family/alice"
|
||||
USER_BASEPATHS: ${USER_BASEPATHS:-}
|
||||
volumes:
|
||||
# Sidecar mutates originals (rename, folder mutations, heap
|
||||
# convert) — always rw regardless of PhotoPrism's mount mode.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:rw,Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
networks:
|
||||
mulita-network:
|
||||
photoprism-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
thumbs_data:
|
||||
db_data:
|
||||
trash_data:
|
||||
redis_data:
|
||||
pp_mariadb_data:
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,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";
|
||||
}
|
||||
}
|
||||
@@ -1,61 +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": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"@tanstack/react-virtual": "^3.0.1",
|
||||
"zustand": "^4.4.7",
|
||||
"framer-motion": "^10.18.0",
|
||||
"axios": "^1.6.5",
|
||||
"date-fns": "^3.2.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"react-hotkeys-hook": "^4.4.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"@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",
|
||||
"lucide-react": "^0.303.0",
|
||||
"react-intersection-observer": "^9.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@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,59 +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 { 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -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,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"]
|
||||
}
|
||||
@@ -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>
|
||||
506
web/src/lib/components/layout/GeneralSettingsDialog.svelte
Normal file
506
web/src/lib/components/layout/GeneralSettingsDialog.svelte
Normal file
@@ -0,0 +1,506 @@
|
||||
<!--
|
||||
General app preferences. The UI tab owns the SvelteKit shell's
|
||||
light/dark/system theme (mode-watcher) plus the per-user UI knobs
|
||||
PhotoPrism's /settings exposes. Search and Maps follow the same
|
||||
pattern — server prefs round-trip via /api/v1/settings.
|
||||
|
||||
The Library admin dialog and this one share the ['settings'] cache,
|
||||
so saves from either invalidate the other.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog, Tabs } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
setUserPassword,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
|
||||
|
||||
// ── Account tab — password change ─────────────────────────────────────
|
||||
let pwOld = $state('');
|
||||
let pwNew = $state('');
|
||||
let pwConfirm = $state('');
|
||||
|
||||
const pwMut = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!session.user) throw new Error('Not signed in');
|
||||
if (pwNew.length < 8) throw new Error('New password must be at least 8 characters');
|
||||
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
|
||||
await setUserPassword(session.user.UID, pwOld, pwNew);
|
||||
},
|
||||
onSuccess: () => {
|
||||
pwOld = '';
|
||||
pwNew = '';
|
||||
pwConfirm = '';
|
||||
toast.success('Password updated');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not update password')
|
||||
}));
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', Icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||
{ value: 'system', label: 'System', Icon: Monitor }
|
||||
] as const;
|
||||
|
||||
// PhotoPrism palette names from its built-in themes. Any value
|
||||
// outside this list is preserved verbatim (see `withCurrent`).
|
||||
const ppThemes = [
|
||||
'default',
|
||||
'abyss',
|
||||
'gemstone',
|
||||
'grayscale',
|
||||
'lavender',
|
||||
'legacy',
|
||||
'neon',
|
||||
'onyx',
|
||||
'raspberry',
|
||||
'shadow',
|
||||
'yellowstone'
|
||||
];
|
||||
|
||||
// IETF subtags PhotoPrism ships translations for. Extend without
|
||||
// fear — `withCurrent` keeps unknown values visible.
|
||||
const ppLanguages = [
|
||||
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
|
||||
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
|
||||
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
|
||||
];
|
||||
|
||||
const ppStartPages = [
|
||||
'default',
|
||||
'browse',
|
||||
'albums',
|
||||
'calendar',
|
||||
'moments',
|
||||
'people',
|
||||
'places',
|
||||
'labels',
|
||||
'states',
|
||||
'library'
|
||||
];
|
||||
|
||||
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
|
||||
|
||||
// Returns `opts` with `current` prepended if it's set and not
|
||||
// already in the list — so e.g. an experimental theme name in the
|
||||
// server response shows up selected and editable instead of
|
||||
// silently being overwritten by the dropdown's default.
|
||||
function withCurrent(opts: string[], current?: string): string[] {
|
||||
if (!current) return opts;
|
||||
return opts.includes(current) ? opts : [current, ...opts];
|
||||
}
|
||||
|
||||
const settingsQuery = createQuery<PpSettings>(() => ({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
enabled: open
|
||||
}));
|
||||
|
||||
/**
|
||||
* Some PhotoPrism deployments return `/settings` without the
|
||||
* `ui` / `search` / `maps` keys (older versions, custom edits to
|
||||
* settings.yml). The form's `bind:value={draft.ui!.theme}` etc.
|
||||
* non-null-asserts those sub-objects — when they're missing the
|
||||
* assertion lies and the bind getter throws on the next tick. Force
|
||||
* the shape on every clone so every binding has a real object to
|
||||
* write into, and so `draft.ui` is never null while `draft` is non-
|
||||
* null (template gates only check `draft`).
|
||||
*/
|
||||
function normalize(s: PpSettings): PpSettings {
|
||||
return {
|
||||
...s,
|
||||
ui: s.ui ?? {},
|
||||
search: s.search ?? {},
|
||||
maps: s.maps ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
let draft = $state<PpSettings | null>(null);
|
||||
// Re-clone on each open so reopening the dialog shows the freshest
|
||||
// server state. Eagerly nulling on close used to introduce a window
|
||||
// where Dialog's exit animation kept the form mounted while draft
|
||||
// was already null — and bind:value getters read null, triggering
|
||||
// "$.get(...) is null" / can't access .ui at runtime. Resetting on
|
||||
// open instead avoids that race entirely.
|
||||
$effect(() => {
|
||||
if (open && settingsQuery.data) {
|
||||
draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
});
|
||||
|
||||
const saveMut = createMutation(() => ({
|
||||
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(['settings'], next);
|
||||
draft = normalize(structuredClone(next));
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not save settings')
|
||||
}));
|
||||
|
||||
function resetDraft() {
|
||||
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
{open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[560px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Dialog.Title class="text-sm font-semibold leading-tight">
|
||||
General settings
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
Preferences for Mulimage and your account. Library admin lives
|
||||
under Folders → ⚙.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
|
||||
{#each ['ui', 'search', 'maps', 'account'] as const as t (t)}
|
||||
<Tabs.Trigger
|
||||
value={t}
|
||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||
>
|
||||
{t}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<!-- UI — local app theme (mode-watcher) on top, then the
|
||||
PhotoPrism per-user UI knobs that go to /settings. -->
|
||||
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
App theme
|
||||
</h3>
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-md border border-border"
|
||||
role="group"
|
||||
aria-label="Theme"
|
||||
>
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
{@const active = mode.current === opt.value}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => setMode(opt.value)}
|
||||
>
|
||||
<opt.Icon class="h-3.5 w-3.5" />
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Light/dark for this app. Persists locally; no Save needed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if settingsQuery.isPending}
|
||||
<p class="px-1 text-muted-foreground">Loading server settings…</p>
|
||||
{:else if settingsQuery.isError}
|
||||
<p class="px-1 text-destructive">Could not load server settings.</p>
|
||||
{:else if draft}
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Server UI
|
||||
</h3>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Theme</span>
|
||||
<select bind:value={draft.ui!.theme} class={selectClass}>
|
||||
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Language</span>
|
||||
<select bind:value={draft.ui!.language} class={selectClass}>
|
||||
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Time zone</span>
|
||||
<!-- IANA tz list is ~400 entries, browser support varies; use
|
||||
a datalist so we get autocomplete without spamming a
|
||||
gigantic <select>. "Local" is PhotoPrism's special
|
||||
"follow system" sentinel. -->
|
||||
<input
|
||||
type="text"
|
||||
list="general-tz-list"
|
||||
placeholder="Local"
|
||||
bind:value={draft.ui!.timeZone}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Start page</span>
|
||||
<select bind:value={draft.ui!.startPage} class={selectClass}>
|
||||
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
|
||||
Always show scrollbars
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.ui!.zoom} />
|
||||
Allow image zoom
|
||||
</label>
|
||||
</section>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||
</Tabs.Content>
|
||||
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<p class="px-1 text-[12px] text-destructive">
|
||||
Could not load settings.
|
||||
</p>
|
||||
</Tabs.Content>
|
||||
{:else if draft}
|
||||
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.listView} />
|
||||
Default to list view
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.showTitles} />
|
||||
Show titles
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
|
||||
Show captions
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">
|
||||
Batch size (-1 = server default)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft.search!.batchSize}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Style</span>
|
||||
<select bind:value={draft.maps!.style} class={selectClass}>
|
||||
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">
|
||||
Animation duration (ms, 0 = off)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft.maps!.animate}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
{/if}
|
||||
|
||||
<!-- Account — independent of /settings; reads from the session
|
||||
store and round-trips its own mutation. -->
|
||||
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Signed in as
|
||||
</h3>
|
||||
<div class="space-y-1 rounded border border-border bg-muted/30 p-2">
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted-foreground">Name</span>
|
||||
<span class="font-medium">{session.user?.Name ?? '—'}</span>
|
||||
</div>
|
||||
{#if session.user?.DisplayName}
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted-foreground">Display name</span>
|
||||
<span>{session.user.DisplayName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.user?.Email}
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted-foreground">Email</span>
|
||||
<span>{session.user.Email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted-foreground">Role</span>
|
||||
<span>{session.user?.Role ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
class="space-y-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
pwMut.mutate();
|
||||
}}
|
||||
>
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Change password
|
||||
</h3>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Current password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={pwOld}
|
||||
required
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">New password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={pwNew}
|
||||
required
|
||||
minlength={8}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Confirm new password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={pwConfirm}
|
||||
required
|
||||
minlength={8}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={pwMut.isPending ||
|
||||
!pwOld ||
|
||||
pwNew.length < 8 ||
|
||||
pwNew !== pwConfirm}
|
||||
>
|
||||
{#if pwMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Update password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Datalist for time-zone autocomplete. Falls back to the
|
||||
"Local" sentinel when the browser can't enumerate the
|
||||
IANA list (older Safari, etc.). -->
|
||||
<datalist id="general-tz-list">
|
||||
<option value="Local"></option>
|
||||
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
|
||||
</datalist>
|
||||
|
||||
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
|
||||
trip). The App theme group above persists itself, so we
|
||||
only show the action row when there's something to save.
|
||||
Account tab has its own Update-password button, so skip. -->
|
||||
{#if draft && activeTab !== 'account'}
|
||||
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={resetDraft}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => draft && saveMut.mutate(draft)}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
{#if saveMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<script lang="ts" module>
|
||||
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
|
||||
// 15.3 and below) return undefined here. The component handles that
|
||||
// by simply showing only the "Local" sentinel in the datalist.
|
||||
export function tzOptions(): string[] {
|
||||
const fn = (Intl as unknown as {
|
||||
supportedValuesOf?: (k: string) => string[];
|
||||
}).supportedValuesOf;
|
||||
if (typeof fn !== 'function') return [];
|
||||
try {
|
||||
return fn('timeZone');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
249
web/src/lib/components/layout/HeapConvertDialog.svelte
Normal file
249
web/src/lib/components/layout/HeapConvertDialog.svelte
Normal file
@@ -0,0 +1,249 @@
|
||||
<!--
|
||||
Move/copy every photo in a heap into a folder under originals/.
|
||||
|
||||
Picker reuses the existing FolderTree in readonly mode; the dialog owns
|
||||
the selection (`pickedPath`) so it doesn't conflict with the global
|
||||
folderPath filter the sidebar drives.
|
||||
|
||||
Submit goes to the sidecar's POST /albums/:uid/convert. On success we
|
||||
invalidate the photos / folders / heaps queries so the timeline and
|
||||
sidebar refresh; if the heap was deleted and was active, route home.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import {
|
||||
convertHeap,
|
||||
listFolders,
|
||||
type HeapConvertBody,
|
||||
type HeapConvertResult,
|
||||
type PpAlbum,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import { filters, setSection } from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
|
||||
interface Props {
|
||||
heap: PpAlbum | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { heap, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Reuse the same folders cache the sidebar uses — same key so we share
|
||||
// the in-flight request, and the picker invalidates it on success.
|
||||
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
const folderTree = $derived(
|
||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
let pickedPath = $state<string | null>(null);
|
||||
let mode = $state<'move' | 'copy'>('move');
|
||||
let subfolder = $state('');
|
||||
let deleteHeap = $state(false);
|
||||
|
||||
// Reset draft state whenever a new heap is picked (or the dialog closes
|
||||
// and reopens). $effect runs after the prop change, so the form is
|
||||
// blank on every fresh open.
|
||||
$effect(() => {
|
||||
void heap;
|
||||
pickedPath = null;
|
||||
mode = 'move';
|
||||
subfolder = '';
|
||||
deleteHeap = false;
|
||||
});
|
||||
|
||||
const convertMut = createMutation(() => ({
|
||||
mutationFn: (args: { uid: string; body: HeapConvertBody }) =>
|
||||
convertHeap(args.uid, args.body),
|
||||
onSuccess: (result: HeapConvertResult, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
const verb = mode === 'copy' ? 'Copied' : 'Moved';
|
||||
const count = mode === 'copy' ? result.copied : result.moved;
|
||||
const tail =
|
||||
result.errors.length > 0
|
||||
? ` · ${result.errors.length} skipped`
|
||||
: '';
|
||||
toast.success(`${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`);
|
||||
// If the heap got deleted and we were viewing it, fall back home.
|
||||
if (
|
||||
result.heap_deleted &&
|
||||
filters.section === 'heap' &&
|
||||
filters.heapUid === vars.uid
|
||||
) {
|
||||
setSection('all-photos');
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Convert failed')
|
||||
}));
|
||||
|
||||
function submit() {
|
||||
// pickedPath === '' is the root selection; falsy check would
|
||||
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
||||
if (!heap || pickedPath === null) return;
|
||||
// pickedPath is user-relative (listFolders strips BasePath). The
|
||||
// sidecar moves files on disk so it needs a server-absolute path —
|
||||
// translate before submitting.
|
||||
convertMut.mutate({
|
||||
uid: heap.UID,
|
||||
body: {
|
||||
targetFolder: toOriginalsPath(pickedPath),
|
||||
mode,
|
||||
subfolder: subfolder.trim() || null,
|
||||
deleteHeap: mode === 'move' && deleteHeap
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy mode doesn't change membership, so "delete heap after" is
|
||||
// meaningless. Force-clear it when the user flips back to copy.
|
||||
$effect(() => {
|
||||
if (mode === 'copy' && deleteHeap) deleteHeap = false;
|
||||
});
|
||||
|
||||
const open = $derived(heap !== null);
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
{open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Dialog.Title class="text-sm font-semibold leading-tight">
|
||||
{mode === 'copy' ? 'Copy' : 'Move'} heap to folder
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
{heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder picker. Readonly FolderTree so the user can't kebab/
|
||||
rename their way out of the picker mid-flow. -->
|
||||
<div class="rounded-md border border-border bg-background p-2">
|
||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Destination
|
||||
</div>
|
||||
<div class="max-h-[200px] overflow-y-auto">
|
||||
{#if foldersQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if (foldersQuery.data ?? []).length === 0}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={FolderOpen}
|
||||
title="No folders"
|
||||
description="Create one from the sidebar first."
|
||||
/>
|
||||
{:else}
|
||||
<!-- Root row: lets the user drop the heap directly into
|
||||
originals/ without picking a subfolder. The empty
|
||||
string is the sidecar's "root" sentinel — matches
|
||||
resolveUnderRoot's special case in handlers_heap. -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-primary={pickedPath === ''}
|
||||
class:text-primary-foreground={pickedPath === ''}
|
||||
class:hover:bg-primary={pickedPath === ''}
|
||||
onclick={() => (pickedPath = '')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
onPick={(p) => (pickedPath = p)}
|
||||
selectedPath={pickedPath}
|
||||
readonly
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode + options. Plain radio + checkbox; bits-ui has dedicated
|
||||
primitives but inline form controls keep the dialog small. -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-4 text-[12px]">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" bind:group={mode} value="move" />
|
||||
Move
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" bind:group={mode} value="copy" />
|
||||
Copy
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1 text-[12px]">
|
||||
<span class="text-muted-foreground">
|
||||
New subfolder (optional)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. {heap?.Title ?? 'My heap'}"
|
||||
bind:value={subfolder}
|
||||
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={deleteHeap}
|
||||
disabled={mode === 'copy'}
|
||||
/>
|
||||
<span class:text-muted-foreground={mode === 'copy'}>
|
||||
Delete heap after move
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={onClose}
|
||||
disabled={convertMut.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={submit}
|
||||
disabled={pickedPath === null || convertMut.isPending}
|
||||
>
|
||||
{#if convertMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
{mode === 'copy' ? 'Copy' : 'Move'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
49
web/src/lib/components/layout/IndexerStatusPill.svelte
Normal file
49
web/src/lib/components/layout/IndexerStatusPill.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<!--
|
||||
Compact status pill that appears in the header while PhotoPrism's
|
||||
indexer is doing work. Driven by the indexer store, which subscribes
|
||||
to PhotoPrism's WS channel. Renders nothing when idle so it never
|
||||
steals header real estate from the user.
|
||||
|
||||
The `detail` (current path/file) is exposed via `title` rather than
|
||||
rendered inline — the pill stays narrow even on slow flashes through
|
||||
a deep library, and hover surfaces the detail for users who care.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { indexer } from '$lib/stores/indexer.svelte';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
// PhotoPrism's `fileName` arrives as the full relative path
|
||||
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
|
||||
// recognition; the full path stays in the `title` for users who hover.
|
||||
const basename = $derived.by(() => {
|
||||
const d = indexer.detail;
|
||||
if (!d) return '';
|
||||
const i = d.lastIndexOf('/');
|
||||
return i >= 0 ? d.slice(i + 1) : d;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if indexer.active || indexer.label}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
title={indexer.detail ?? indexer.label}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if indexer.active}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{indexer.label}</span>
|
||||
{#if basename}
|
||||
<!-- Fixed-width slot so the pill stops shrinking/growing as
|
||||
PhotoPrism rattles through files of different name lengths.
|
||||
`w-[24ch]` locks the column; `truncate` ellipsises anything
|
||||
longer. The full path remains in the parent's `title`. -->
|
||||
<span
|
||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{basename}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
56
web/src/lib/components/layout/KebabMenu.svelte
Normal file
56
web/src/lib/components/layout/KebabMenu.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
Thin wrapper around bits-ui's DM. Provides:
|
||||
- A round ⋯ trigger button styled like the rest of the sidebar's hover
|
||||
affordances (muted, becomes accent on hover/open).
|
||||
- A portal-positioned content container with shadcn-zinc styling.
|
||||
- An `Item` re-export consumers compose into the menu body so we don't
|
||||
also have to redeclare the item styling at every call site.
|
||||
|
||||
Items are passed as a snippet via `children` so callers can mix the
|
||||
`Item` re-export, separators, or destructive variants freely.
|
||||
-->
|
||||
<script lang="ts" module>
|
||||
import { DropdownMenu as DM } from 'bits-ui';
|
||||
export const Item = DM.Item;
|
||||
export const Separator = DM.Separator;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { MoreHorizontal } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
/** Tooltip + aria-label for the trigger button. */
|
||||
label?: string;
|
||||
/** Force the trigger visible regardless of hover state. Used when
|
||||
* the menu is open so it doesn't disappear underneath a row hover
|
||||
* transition while the user is interacting with it. */
|
||||
alwaysVisible?: boolean;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { label = 'More', alwaysVisible = false, children }: Props = $props();
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<DM.Root bind:open>
|
||||
<DM.Trigger
|
||||
class="rounded p-0.5 text-xs text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus:outline-none {open ||
|
||||
alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'}"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal class="h-3.5 w-3.5" />
|
||||
</DM.Trigger>
|
||||
<DM.Portal>
|
||||
<DM.Content
|
||||
class="z-50 min-w-[180px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none"
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
>
|
||||
{@render children()}
|
||||
</DM.Content>
|
||||
</DM.Portal>
|
||||
</DM.Root>
|
||||
1079
web/src/lib/components/layout/LeftSidebar.svelte
Normal file
1079
web/src/lib/components/layout/LeftSidebar.svelte
Normal file
File diff suppressed because it is too large
Load Diff
743
web/src/lib/components/layout/SettingsDialog.svelte
Normal file
743
web/src/lib/components/layout/SettingsDialog.svelte
Normal file
@@ -0,0 +1,743 @@
|
||||
<!--
|
||||
Library admin dialog. Tabs map 1-to-1 to PhotoPrism's own Library page:
|
||||
general settings, manual index, manual import, server error log.
|
||||
|
||||
Each tab owns its own query/mutation pair via TanStack Query so the data
|
||||
loads on first open and the rest of the app can read the same caches.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog, Tabs } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import {
|
||||
cancelImport,
|
||||
cancelIndex,
|
||||
getConfig,
|
||||
getErrors,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
startImport,
|
||||
startIndex,
|
||||
type ImportBody,
|
||||
type IndexBody,
|
||||
type PpLogEntry,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
import type { PpClientConfig } from '$lib/types/photoprism';
|
||||
import { userBasePath } from '$lib/stores/session.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
|
||||
|
||||
// ── Library tab ───────────────────────────────────────────────────────
|
||||
// Pull settings only while the dialog is open so we don't keep them
|
||||
// warm in the background. Edits work on a local clone; Save POSTs it
|
||||
// back wholesale (PhotoPrism deep-merges server-side).
|
||||
const settingsQuery = createQuery<PpSettings>(() => ({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
enabled: open
|
||||
}));
|
||||
|
||||
/**
|
||||
* Force the shape on every clone so each `bind:value={draft.index!.*}`
|
||||
* etc. has a real object to write into. Older PhotoPrism versions
|
||||
* return /settings without one or more of these sub-objects, and
|
||||
* non-null assertions on a missing sub-object throw on the next tick
|
||||
* when Svelte's bind getter reads through it.
|
||||
*
|
||||
* Same shape-coercion pattern used by GeneralSettingsDialog —
|
||||
* keep them in sync if you add a new top-level group there.
|
||||
*/
|
||||
function normalize(s: PpSettings): PpSettings {
|
||||
return {
|
||||
...s,
|
||||
index: s.index ?? {},
|
||||
import: s.import ?? {},
|
||||
stack: s.stack ?? {},
|
||||
download: s.download ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
let draft = $state<PpSettings | null>(null);
|
||||
// Re-clone on every open so reopening shows the freshest server state.
|
||||
// Resetting on open (not close) avoids the race where bits-ui's exit
|
||||
// animation keeps the form mounted with `draft === null` and the
|
||||
// `bind:value={draft.download!.originals}` getter throws.
|
||||
$effect(() => {
|
||||
if (open && settingsQuery.data) {
|
||||
draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
});
|
||||
|
||||
const saveMut = createMutation(() => ({
|
||||
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(['settings'], next);
|
||||
draft = normalize(structuredClone(next));
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not save settings')
|
||||
}));
|
||||
|
||||
function resetDraft() {
|
||||
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
|
||||
// ── Index tab ─────────────────────────────────────────────────────────
|
||||
// Default the reindex path to the user's BasePath when scoping is on,
|
||||
// so non-admins (and admins-with-BasePath) only rescan their own
|
||||
// subtree. PhotoPrism's /index expects originals-relative paths with
|
||||
// a leading slash; `'/'` means the whole library.
|
||||
const _bp = userBasePath();
|
||||
let indexForm = $state<IndexBody>({
|
||||
path: _bp === '' ? '/' : `/${_bp}`,
|
||||
rescan: false,
|
||||
cleanup: false
|
||||
});
|
||||
const startIndexMut = createMutation(() => ({
|
||||
mutationFn: (b: IndexBody) => startIndex(b),
|
||||
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Index failed')
|
||||
}));
|
||||
const cancelIndexMut = createMutation(() => ({
|
||||
mutationFn: () => cancelIndex(),
|
||||
onSuccess: () => toast.success('Indexing canceled'),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
// ── Import tab ────────────────────────────────────────────────────────
|
||||
let importForm = $state<ImportBody>({ path: '/', move: false, dest: '' });
|
||||
const startImportMut = createMutation(() => ({
|
||||
mutationFn: (b: ImportBody) => startImport(b),
|
||||
onSuccess: (r) => toast.success(r.message || 'Import complete'),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
}));
|
||||
const cancelImportMut = createMutation(() => ({
|
||||
mutationFn: () => cancelImport(),
|
||||
onSuccess: () => toast.success('Import canceled'),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
// ── Logs tab ──────────────────────────────────────────────────────────
|
||||
// Poll while the Logs tab is showing; pause otherwise so the dialog
|
||||
// doesn't burn requests when the user is in another tab.
|
||||
const errorsQuery = createQuery<PpLogEntry[]>(() => ({
|
||||
queryKey: ['errors'],
|
||||
queryFn: () => getErrors({ limit: 200 }),
|
||||
enabled: open && activeTab === 'logs',
|
||||
refetchInterval: open && activeTab === 'logs' ? 5000 : false
|
||||
}));
|
||||
|
||||
// ── About tab ─────────────────────────────────────────────────────────
|
||||
// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
|
||||
// so the About tab never triggers an extra round-trip — config is
|
||||
// already warm by the time the user opens this dialog.
|
||||
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||
queryKey: ['photos', 'config'],
|
||||
queryFn: getConfig,
|
||||
enabled: open && activeTab === 'about'
|
||||
}));
|
||||
|
||||
// PhotoPrism's `flags` is a space-separated bag of feature toggles
|
||||
// ("experimental tensorflow places webdav share download import oidc").
|
||||
// Parse once so the chip grid can render in stable order.
|
||||
const flagSet = $derived.by<Set<string>>(() => {
|
||||
const raw = configQuery.data?.flags ?? '';
|
||||
return new Set(raw.split(/\s+/).filter(Boolean));
|
||||
});
|
||||
|
||||
// Env-driven knobs that don't have a runtime PP API. Listed here so the
|
||||
// About tab can render a "you need to edit .env and restart" help
|
||||
// section instead of pretending these are mutable from the UI.
|
||||
interface EnvKnob {
|
||||
envVar: string;
|
||||
label: string;
|
||||
on: boolean;
|
||||
}
|
||||
const envKnobs = $derived.by<EnvKnob[]>(() => {
|
||||
const f = flagSet;
|
||||
const oidc = configQuery.data?.ext?.oidc?.enabled === true;
|
||||
return [
|
||||
{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
|
||||
{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
|
||||
{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
|
||||
{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
|
||||
{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
|
||||
];
|
||||
});
|
||||
|
||||
// Show the config block collapsed by default — most users only want the
|
||||
// version + counts; the env help is for the rare admin moment.
|
||||
let envHelpOpen = $state(false);
|
||||
|
||||
// Library counts surfaced as a compact 2-column grid. Order matches
|
||||
// what users care about most often (photos, then derived buckets).
|
||||
const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
|
||||
{ key: 'all', label: 'Photos' },
|
||||
{ key: 'videos', label: 'Videos' },
|
||||
{ key: 'live', label: 'Live photos' },
|
||||
{ key: 'favorites', label: 'Favorites' },
|
||||
{ key: 'review', label: 'In review' },
|
||||
{ key: 'archived', label: 'Archived' },
|
||||
{ key: 'hidden', label: 'Hidden' },
|
||||
{ key: 'people', label: 'People' },
|
||||
{ key: 'labels', label: 'Labels' },
|
||||
{ key: 'folders', label: 'Folders' },
|
||||
{ key: 'albums', label: 'Albums' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
{open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Settings class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Dialog.Title class="text-sm font-semibold leading-tight">
|
||||
Library settings
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
Drive the library, indexer, importer and server log.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<Tabs.List
|
||||
class="mb-3 flex gap-1 border-b border-border"
|
||||
>
|
||||
{#each ['library', 'index', 'import', 'logs', 'about'] as const as t (t)}
|
||||
<Tabs.Trigger
|
||||
value={t}
|
||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||
>
|
||||
{t}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Library — general settings -->
|
||||
<Tabs.Content value="library" class="outline-none">
|
||||
{#if settingsQuery.isPending}
|
||||
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||
{:else if settingsQuery.isError}
|
||||
<p class="px-1 text-[12px] text-destructive">
|
||||
Could not load settings.
|
||||
</p>
|
||||
{:else if draft}
|
||||
<div class="max-h-[55vh] space-y-4 overflow-y-auto pr-1 text-[12px]">
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Indexer defaults
|
||||
</h3>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.index!.convert}
|
||||
/>
|
||||
Convert RAW / HEIC on index
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.index!.rescan}
|
||||
/>
|
||||
Rescan known files
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.index!.skipArchived}
|
||||
/>
|
||||
Skip archived photos
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Importer defaults
|
||||
</h3>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.import!.move} />
|
||||
Move (instead of copy) on import
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Default destination subpath</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 2026/05"
|
||||
bind:value={draft.import!.dest}
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Stacks
|
||||
</h3>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.stack!.uuid} />
|
||||
Stack files sharing a UUID
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.stack!.meta} />
|
||||
Stack files with matching metadata
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.stack!.name} />
|
||||
Stack files with matching names
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Downloads
|
||||
</h3>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.download!.originals}
|
||||
/>
|
||||
Originals
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.download!.mediaRaw}
|
||||
/>
|
||||
Include RAW
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.download!.mediaSidecar}
|
||||
/>
|
||||
Include sidecar files
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.download!.disabled}
|
||||
/>
|
||||
Disable downloads entirely
|
||||
</label>
|
||||
{#if draft.download?.crc32 !== undefined}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.download!.crc32}
|
||||
/>
|
||||
Include CRC32 checksum
|
||||
</label>
|
||||
{/if}
|
||||
{#if draft.download?.sha1 !== undefined}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.download!.sha1}
|
||||
/>
|
||||
Include SHA1 checksum
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Indexer advanced — only renders the fields PP actually
|
||||
reported. Older PP versions return a smaller `index`
|
||||
block and we don't want to fabricate UI for missing keys. -->
|
||||
{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Indexer advanced
|
||||
</h3>
|
||||
{#if draft.index?.skipMeta !== undefined}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.index!.skipMeta}
|
||||
/>
|
||||
Skip metadata-only changes
|
||||
</label>
|
||||
{/if}
|
||||
{#if draft.index?.skipRaw !== undefined}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.index!.skipRaw}
|
||||
/>
|
||||
Skip RAW files
|
||||
</label>
|
||||
{/if}
|
||||
{#if draft.index?.skipHidden !== undefined}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.index!.skipHidden}
|
||||
/>
|
||||
Skip hidden files
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Features — PhotoPrism's gating bag. Render only the
|
||||
keys actually present in the response (PP version
|
||||
drift), labelled human-readably. -->
|
||||
{#if draft.features && Object.keys(draft.features).length > 0}
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Features
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Toggling a feature off hides it from PhotoPrism's own
|
||||
UI and disables the underlying API surface.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each Object.keys(draft.features).sort() as key (key)}
|
||||
{#if typeof draft.features![key] === 'boolean'}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.features![key]}
|
||||
/>
|
||||
<span class="capitalize">{key}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={resetDraft}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => draft && saveMut.mutate(draft)}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
{#if saveMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Index — manual indexer run -->
|
||||
<Tabs.Content value="index" class="space-y-3 text-[12px] outline-none">
|
||||
<p class="text-muted-foreground">
|
||||
Rebuilds the search index by walking the originals folder. Safe to
|
||||
run while users are connected.
|
||||
</p>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Path</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={indexForm.path}
|
||||
placeholder="/"
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={indexForm.rescan} />
|
||||
Rescan files already in the index
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={indexForm.cleanup} />
|
||||
Clean up missing files
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
|
||||
onclick={() => cancelIndexMut.mutate()}
|
||||
disabled={cancelIndexMut.isPending || startIndexMut.isPending}
|
||||
>
|
||||
Cancel current
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => startIndexMut.mutate(indexForm)}
|
||||
disabled={startIndexMut.isPending}
|
||||
>
|
||||
{#if startIndexMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Start indexing
|
||||
</button>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Import — manual import run -->
|
||||
<Tabs.Content value="import" class="space-y-3 text-[12px] outline-none">
|
||||
<p class="text-muted-foreground">
|
||||
Pulls files from the import folder into the library. With "move"
|
||||
enabled, files are deleted from the import folder after a
|
||||
successful import.
|
||||
</p>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Source path</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={importForm.path}
|
||||
placeholder="/"
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={importForm.move} />
|
||||
Move files (don't copy) after import
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Destination subpath (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={importForm.dest}
|
||||
placeholder="e.g. 2026/05"
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
|
||||
onclick={() => cancelImportMut.mutate()}
|
||||
disabled={cancelImportMut.isPending || startImportMut.isPending}
|
||||
>
|
||||
Cancel current
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => startImportMut.mutate(importForm)}
|
||||
disabled={startImportMut.isPending}
|
||||
>
|
||||
{#if startImportMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Start import
|
||||
</button>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- About — version, library counts, env-driven config help -->
|
||||
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
|
||||
{#if configQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading server info…" />
|
||||
{:else if configQuery.isError || !configQuery.data}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
tone="destructive"
|
||||
icon={AlertCircle}
|
||||
title="Could not load server info"
|
||||
/>
|
||||
{:else}
|
||||
{@const cfg = configQuery.data}
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Server
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
|
||||
<span class="text-muted-foreground">PhotoPrism</span>
|
||||
<span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
|
||||
<span class="text-muted-foreground">Site</span>
|
||||
<span class="truncate text-right" title={cfg.siteUrl}>
|
||||
{cfg.siteUrl || '—'}
|
||||
</span>
|
||||
<span class="text-muted-foreground">Auth mode</span>
|
||||
<span class="text-right">{cfg.mode}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Features
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each envKnobs as knob (knob.envVar)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
|
||||
? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
|
||||
: 'border-border bg-secondary text-muted-foreground'}"
|
||||
title={knob.envVar}
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full {knob.on
|
||||
? 'bg-green-500'
|
||||
: 'bg-muted-foreground/40'}"
|
||||
></span>
|
||||
{knob.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if cfg.count}
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Library
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
|
||||
{#each COUNT_ROWS as row (row.key)}
|
||||
{@const v = cfg.count?.[row.key]}
|
||||
{#if v !== undefined}
|
||||
<span class="text-muted-foreground">{row.label}</span>
|
||||
<span class="text-right tabular-nums">{v}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Env-driven config: there is no PhotoPrism API for these.
|
||||
The panel surfaces what's on/off and reminds the admin
|
||||
where to flip the switch — .env + restart. -->
|
||||
<section class="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
|
||||
onclick={() => (envHelpOpen = !envHelpOpen)}
|
||||
aria-expanded={envHelpOpen}
|
||||
>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Server configuration (env-driven)
|
||||
</span>
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
{envHelpOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
{#if envHelpOpen}
|
||||
<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
|
||||
<p class="text-muted-foreground">
|
||||
These knobs aren't exposed through the API. Edit
|
||||
<code class="rounded bg-background px-1">.env</code>
|
||||
on the host and restart PhotoPrism:
|
||||
</p>
|
||||
<pre
|
||||
class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
|
||||
>docker compose up -d photoprism
|
||||
# or, with podman-compose:
|
||||
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
|
||||
<ul class="space-y-0.5">
|
||||
{#each envKnobs as knob (knob.envVar)}
|
||||
<li>
|
||||
<code class="rounded bg-background px-1">{knob.envVar}</code>
|
||||
—
|
||||
<span class:text-green-600={knob.on}
|
||||
class:text-muted-foreground={!knob.on}>
|
||||
{knob.on ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if cfg.ext?.oidc?.enabled}
|
||||
<p class="text-muted-foreground">
|
||||
OIDC provider:
|
||||
<span class="text-foreground">
|
||||
{cfg.ext.oidc.provider ?? '—'}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Logs — recent server errors -->
|
||||
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-muted-foreground">
|
||||
Most recent server errors and warnings. Auto-refreshes every
|
||||
5 seconds.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
|
||||
onclick={() => qc.invalidateQueries({ queryKey: ['errors'] })}
|
||||
disabled={errorsQuery.isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3 w-3 {errorsQuery.isFetching ? 'animate-spin' : ''}"
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{#if errorsQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading error log…" />
|
||||
{:else if errorsQuery.isError}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
tone="destructive"
|
||||
icon={AlertCircle}
|
||||
title="Could not load error log"
|
||||
/>
|
||||
{:else if (errorsQuery.data ?? []).length === 0}
|
||||
<EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
|
||||
{:else}
|
||||
<ul
|
||||
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"
|
||||
>
|
||||
{#each errorsQuery.data ?? [] as entry, i (i)}
|
||||
<li class="leading-snug">
|
||||
<span class="text-muted-foreground">{entry.Time}</span>
|
||||
<span
|
||||
class:text-destructive={entry.Level === 'error'}
|
||||
class:text-yellow-500={entry.Level === 'warn' ||
|
||||
entry.Level === 'warning'}
|
||||
>
|
||||
[{entry.Level}]
|
||||
</span>
|
||||
{entry.Message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
75
web/src/lib/components/layout/Toolbar.svelte
Normal file
75
web/src/lib/components/layout/Toolbar.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
Thin sub-header bar that sits below the AnimatedMule. Matches the legacy
|
||||
mule-image FilterBar height (h-9) and toggle layout: left-sidebar toggle
|
||||
pinned to the far-left edge, right-sidebar toggle pinned to the far-right.
|
||||
Page-specific content (section badge, search, etc.) goes in the middle,
|
||||
and page-specific buttons (dark-mode, sign-out, route counts…) live in
|
||||
the trailing slot.
|
||||
|
||||
The bar is sticky-top so it stays visible as the timeline scrolls past
|
||||
the animated header above.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
PanelLeftOpen,
|
||||
PanelLeftClose,
|
||||
PanelRightOpen,
|
||||
PanelRightClose
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
toggleLeftSidebar,
|
||||
toggleRightSidebar,
|
||||
view
|
||||
} from '$lib/stores/view.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Render the right-sidebar toggle. Routes without a right panel
|
||||
* (map, ratings, colors) leave this off. /tags toggles it on
|
||||
* only while drilled into a category, where the photo grid is
|
||||
* showing real photos with metadata. */
|
||||
showRightToggle?: boolean;
|
||||
children?: import('svelte').Snippet;
|
||||
trailing?: import('svelte').Snippet;
|
||||
}
|
||||
let { showRightToggle = false, children, trailing }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex h-9 shrink-0 items-center gap-3 border-b border-border bg-background/80 px-3 backdrop-blur"
|
||||
>
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={toggleLeftSidebar}
|
||||
title={view.leftSidebarCollapsed ? 'Expand nav (b)' : 'Collapse nav (b)'}
|
||||
aria-label={view.leftSidebarCollapsed ? 'Expand left panel' : 'Collapse left panel'}
|
||||
>
|
||||
{#if view.leftSidebarCollapsed}
|
||||
<PanelLeftOpen class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<PanelLeftClose class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{@render trailing?.()}
|
||||
</div>
|
||||
|
||||
{#if showRightToggle}
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={toggleRightSidebar}
|
||||
title={view.rightSidebarCollapsed ? 'Show info (i)' : 'Hide info (i)'}
|
||||
aria-label={view.rightSidebarCollapsed ? 'Show right panel' : 'Hide right panel'}
|
||||
>
|
||||
{#if view.rightSidebarCollapsed}
|
||||
<PanelRightOpen class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<PanelRightClose class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
484
web/src/lib/components/layout/UsersDialog.svelte
Normal file
484
web/src/lib/components/layout/UsersDialog.svelte
Normal file
@@ -0,0 +1,484 @@
|
||||
<!--
|
||||
Admin-only user management. PhotoPrism exposes /api/v1/users CRUD; this
|
||||
dialog wraps it in a list/edit two-pane so the PP web UI never has to be
|
||||
opened for routine user changes. Mounted from LeftSidebar's footer
|
||||
(visible only when session.user.Role === 'admin').
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Loader2, Plus, Trash2, Users as UsersIcon, X } from 'lucide-svelte';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
setUserPassword,
|
||||
updateUser,
|
||||
type CreateUserBody
|
||||
} from '$lib/services/photoprism';
|
||||
import type { PpRole, PpUser } from '$lib/types/photoprism';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const usersQuery = createQuery<PpUser[]>(() => ({
|
||||
queryKey: ['users'],
|
||||
queryFn: listUsers,
|
||||
enabled: open
|
||||
}));
|
||||
|
||||
// Selection state: a UID picks an existing user from the list; `null`
|
||||
// means "no selection" (right pane empty); `'new'` opens the new-user
|
||||
// form. Reset whenever the dialog opens so reopening doesn't strand a
|
||||
// stale form.
|
||||
type Selection = string | 'new' | null;
|
||||
let selection = $state<Selection>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) selection = null;
|
||||
});
|
||||
|
||||
const ROLES: PpRole[] = ['admin', 'user', 'contributor', 'guest', 'visitor'];
|
||||
|
||||
// Editable copy of the selected user. Re-cloned on every selection
|
||||
// change so the form starts from the server-side snapshot (and a
|
||||
// failed save doesn't leak stale values into the next selection).
|
||||
let draft = $state<EditableUser>(emptyDraft());
|
||||
|
||||
interface EditableUser {
|
||||
UID: string;
|
||||
Name: string;
|
||||
DisplayName: string;
|
||||
Email: string;
|
||||
Role: PpRole;
|
||||
BasePath: string;
|
||||
UploadPath: string;
|
||||
WebDAV: boolean;
|
||||
Password: string;
|
||||
}
|
||||
|
||||
function emptyDraft(): EditableUser {
|
||||
return {
|
||||
UID: '',
|
||||
Name: '',
|
||||
DisplayName: '',
|
||||
Email: '',
|
||||
Role: 'user',
|
||||
BasePath: '',
|
||||
UploadPath: '',
|
||||
WebDAV: false,
|
||||
Password: ''
|
||||
};
|
||||
}
|
||||
|
||||
function userToDraft(u: PpUser): EditableUser {
|
||||
return {
|
||||
UID: u.UID,
|
||||
Name: u.Name ?? '',
|
||||
DisplayName: u.DisplayName ?? '',
|
||||
Email: u.Email ?? '',
|
||||
Role: u.Role ?? 'user',
|
||||
BasePath: u.BasePath ?? '',
|
||||
UploadPath: u.UploadPath ?? '',
|
||||
// Server may or may not return WebDAV depending on PP version;
|
||||
// default to false rather than guessing the current value.
|
||||
WebDAV: Boolean((u as PpUser & { WebDAV?: boolean }).WebDAV),
|
||||
Password: ''
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selection === 'new') {
|
||||
draft = emptyDraft();
|
||||
} else if (selection) {
|
||||
const u = (usersQuery.data ?? []).find((x) => x.UID === selection);
|
||||
if (u) draft = userToDraft(u);
|
||||
} else {
|
||||
draft = emptyDraft();
|
||||
}
|
||||
});
|
||||
|
||||
// Password sub-form (only relevant when editing an existing user).
|
||||
// Decoupled from `draft` because the password endpoint is a separate
|
||||
// PUT and never goes through createUser/updateUser.
|
||||
let pwNew = $state('');
|
||||
let pwConfirm = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Reset password fields whenever the selection changes.
|
||||
void selection;
|
||||
pwNew = '';
|
||||
pwConfirm = '';
|
||||
});
|
||||
|
||||
function toBody(d: EditableUser): CreateUserBody {
|
||||
const body: CreateUserBody = {
|
||||
Name: d.Name.trim(),
|
||||
Role: d.Role
|
||||
};
|
||||
if (d.DisplayName.trim()) body.DisplayName = d.DisplayName.trim();
|
||||
if (d.Email.trim()) body.Email = d.Email.trim();
|
||||
if (d.BasePath.trim()) body.BasePath = d.BasePath.trim();
|
||||
if (d.UploadPath.trim()) body.UploadPath = d.UploadPath.trim();
|
||||
body.WebDAV = d.WebDAV;
|
||||
return body;
|
||||
}
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!draft.Name.trim()) throw new Error('Username is required');
|
||||
if (!draft.Password || draft.Password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
const body = toBody(draft);
|
||||
body.Password = draft.Password;
|
||||
return createUser(body);
|
||||
},
|
||||
onSuccess: (u) => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success(`Created user ${u.Name}`);
|
||||
selection = u.UID;
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not create user')
|
||||
}));
|
||||
|
||||
const updateMut = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!selection || selection === 'new') throw new Error('No user selected');
|
||||
return updateUser(selection, toBody(draft));
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User updated');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not update user')
|
||||
}));
|
||||
|
||||
const deleteMut = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!selection || selection === 'new') throw new Error('No user selected');
|
||||
return deleteUser(selection);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User deleted');
|
||||
selection = null;
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not delete user')
|
||||
}));
|
||||
|
||||
const pwMut = createMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
if (!selection || selection === 'new') throw new Error('No user selected');
|
||||
if (pwNew.length < 8) throw new Error('Password must be at least 8 characters');
|
||||
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
|
||||
// Admin-issued password reset: PhotoPrism accepts an empty `old`
|
||||
// when the caller is an admin acting on another user.
|
||||
await setUserPassword(selection, '', pwNew);
|
||||
},
|
||||
onSuccess: () => {
|
||||
pwNew = '';
|
||||
pwConfirm = '';
|
||||
toast.success('Password updated');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not update password')
|
||||
}));
|
||||
|
||||
function onDeleteClick() {
|
||||
if (!draft.Name) return;
|
||||
if (!confirm(`Delete user "${draft.Name}"? This cannot be undone.`)) return;
|
||||
deleteMut.mutate();
|
||||
}
|
||||
|
||||
const isSelf = $derived(
|
||||
selection !== 'new' && selection !== null && selection === session.user?.UID
|
||||
);
|
||||
|
||||
const inputClass =
|
||||
'rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
{open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[760px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<UsersIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Dialog.Title class="text-sm font-semibold leading-tight">Users</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
Manage accounts. Roles + per-user library paths come from the
|
||||
server's ACL — changes apply immediately.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[220px_1fr] gap-4">
|
||||
<!-- Left pane: user list + new-user trigger. -->
|
||||
<div class="flex max-h-[480px] flex-col overflow-hidden rounded border border-border">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-border px-2 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-accent={selection === 'new'}
|
||||
onclick={() => (selection = 'new')}
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
<span>New user</span>
|
||||
</button>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if usersQuery.isPending}
|
||||
<p class="px-2 py-2 text-[12px] text-muted-foreground">Loading…</p>
|
||||
{:else if usersQuery.isError}
|
||||
<p class="px-2 py-2 text-[12px] text-destructive">
|
||||
Could not load users.
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each usersQuery.data ?? [] as u (u.UID)}
|
||||
{@const active = selection === u.UID}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-0.5 border-b border-border/40 px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-accent={active}
|
||||
onclick={() => (selection = u.UID)}
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium">
|
||||
{u.DisplayName?.trim() || u.Name}
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded bg-secondary px-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{u.Role}
|
||||
</span>
|
||||
</span>
|
||||
{#if u.BasePath}
|
||||
<span class="truncate text-[11px] text-muted-foreground">
|
||||
{u.BasePath}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right pane: edit form for selected user (or empty/new form). -->
|
||||
<div class="min-w-0">
|
||||
{#if selection === null}
|
||||
<div
|
||||
class="flex h-full min-h-[280px] items-center justify-center rounded border border-dashed border-border p-4 text-center text-[12px] text-muted-foreground"
|
||||
>
|
||||
Pick a user on the left, or click "New user" to create one.
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
class="space-y-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (selection === 'new') createMut.mutate();
|
||||
else updateMut.mutate();
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
Username
|
||||
<span class="text-destructive">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft.Name}
|
||||
required
|
||||
autocomplete="off"
|
||||
disabled={selection !== 'new'}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">Display name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft.DisplayName}
|
||||
autocomplete="off"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={draft.Email}
|
||||
autocomplete="off"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">Role</span>
|
||||
<select bind:value={draft.Role} class={inputClass}>
|
||||
{#each ROLES as r (r)}
|
||||
<option value={r}>{r}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
Base path
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft.BasePath}
|
||||
placeholder="e.g. alice"
|
||||
autocomplete="off"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
Upload path
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft.UploadPath}
|
||||
autocomplete="off"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-[12px]">
|
||||
<input type="checkbox" bind:checked={draft.WebDAV} />
|
||||
Allow WebDAV access
|
||||
</label>
|
||||
|
||||
{#if selection === 'new'}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
Initial password <span class="text-destructive">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={draft.Password}
|
||||
required
|
||||
minlength={8}
|
||||
autocomplete="new-password"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t border-border pt-3">
|
||||
{#if selection !== 'new'}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded border border-destructive/40 px-3 py-1 text-[12px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
onclick={onDeleteClick}
|
||||
disabled={isSelf || deleteMut.isPending}
|
||||
title={isSelf ? 'Cannot delete yourself' : 'Delete user'}
|
||||
>
|
||||
{#if deleteMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="h-3 w-3" />
|
||||
{/if}
|
||||
Delete
|
||||
</button>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={createMut.isPending || updateMut.isPending || !draft.Name.trim()}
|
||||
>
|
||||
{#if createMut.isPending || updateMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
{selection === 'new' ? 'Create user' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if selection !== 'new'}
|
||||
<!-- Admin-issued password reset. Separate from the user's own
|
||||
password change in GeneralSettingsDialog (which requires
|
||||
their current password); admins reset without old-pw. -->
|
||||
<form
|
||||
class="mt-4 space-y-3 rounded border border-border bg-muted/30 p-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
pwMut.mutate();
|
||||
}}
|
||||
>
|
||||
<h4 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Reset password
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">New password</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={pwNew}
|
||||
minlength={8}
|
||||
autocomplete="new-password"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-[11px] text-muted-foreground">Confirm</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={pwConfirm}
|
||||
minlength={8}
|
||||
autocomplete="new-password"
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center gap-1.5 rounded border border-border px-3 py-1 text-[12px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={pwMut.isPending || pwNew.length < 8 || pwNew !== pwConfirm}
|
||||
>
|
||||
{#if pwMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Set password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
107
web/src/lib/components/mule/AnimatedMule.svelte
Normal file
107
web/src/lib/components/mule/AnimatedMule.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<!--
|
||||
Ported pixel-art header from the legacy mule-image React TopBar.
|
||||
- Tiled `desert.png` scrolling right→left under a dusk gradient.
|
||||
- 3×2 sprite-sheet of the mule cycling at 6 frames / 0.6s for a walk.
|
||||
- ASCII "Mulimago" wordmark on a black plate so the mule has company.
|
||||
|
||||
The PNGs live in /static/mule/ so SvelteKit's static handler serves them
|
||||
at /mule/*; the import-via-Vite trick from the React version isn't
|
||||
necessary here.
|
||||
-->
|
||||
<script lang="ts">
|
||||
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
|
||||
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
|
||||
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`;
|
||||
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="mule-header relative flex h-14 items-center justify-between overflow-hidden border-b border-border px-4"
|
||||
>
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
|
||||
<pre
|
||||
aria-label="Mulimago"
|
||||
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
|
||||
style="letter-spacing: 0;">{MULIMAGO_ASCII}</pre>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center gap-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Two layers in the background: tiled desert.png on top scrolling
|
||||
* right→left, dusk-sky gradient underneath. The 200px tile width is
|
||||
* fixed so the `desert-scroll` keyframe moves by exactly one tile and
|
||||
* loops seamlessly.
|
||||
*/
|
||||
.mule-header {
|
||||
background-image: url("/mule/desert.png"),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
#2b3a5c 0%,
|
||||
#6b6b8a 35%,
|
||||
#d68a5c 75%,
|
||||
#f0c188 100%
|
||||
);
|
||||
background-repeat: repeat-x, no-repeat;
|
||||
background-size:
|
||||
200px 100%,
|
||||
100% 100%;
|
||||
background-position:
|
||||
0 bottom,
|
||||
0 0;
|
||||
image-rendering: pixelated;
|
||||
animation: desert-scroll 24s linear infinite;
|
||||
}
|
||||
|
||||
/* 3×2 sprite-sheet, 6-frame walk cycle. `steps(1)` makes each keyframe
|
||||
* snap (no interpolation between frames). */
|
||||
.mule-sprite {
|
||||
background-image: url("/mule/mule-sprites.png");
|
||||
background-size: 300% 200%;
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: pixelated;
|
||||
animation: mule-walk 0.6s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes mule-walk {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
16.66% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
33.33% {
|
||||
background-position: 100% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
66.66% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
83.33% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes desert-scroll {
|
||||
from {
|
||||
background-position-x: 0px, 0px;
|
||||
}
|
||||
to {
|
||||
background-position-x: -200px, 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
web/src/lib/components/preview/PreviewCarousel.svelte
Normal file
173
web/src/lib/components/preview/PreviewCarousel.svelte
Normal file
@@ -0,0 +1,173 @@
|
||||
<!--
|
||||
Bottom filmstrip for the full-screen PreviewModal. Walks the same
|
||||
`selection.order` list the host view uses, so the user steps through
|
||||
their current context (timeline, drill-in, etc.) without surprises.
|
||||
|
||||
Windowed: only renders a slice of ±WINDOW around the focused index, so
|
||||
a 10k-photo timeline doesn't paint 10k thumbs. The slice shifts as
|
||||
focus moves; the strip re-scrolls the focused tile into view on every
|
||||
change.
|
||||
|
||||
Thumbnails are resolved from TanStack's cache so this surface stays
|
||||
side-effect-free (no extra fetches just to draw a strip).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
selectOnly,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused,
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
/** Tiles either side of focus to render. ±50 = ~100 thumbs in DOM at
|
||||
* any time, plenty to cover normal arrow-skim without bloating. */
|
||||
const WINDOW = 50;
|
||||
|
||||
function lookup(uid: string): PpPhoto | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return direct;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const order = $derived(selection.order);
|
||||
const focused = $derived(selection.focused);
|
||||
const focusedIdx = $derived(focused ? order.indexOf(focused) : -1);
|
||||
|
||||
interface Tile {
|
||||
uid: string;
|
||||
hash: string | null;
|
||||
video: boolean;
|
||||
idx: number;
|
||||
}
|
||||
const slice = $derived.by<Tile[]>(() => {
|
||||
if (focusedIdx < 0) return [];
|
||||
const lo = Math.max(0, focusedIdx - WINDOW);
|
||||
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
|
||||
const out: Tile[] = [];
|
||||
for (let i = lo; i < hi; i++) {
|
||||
const photo = lookup(order[i]);
|
||||
out.push({
|
||||
uid: order[i],
|
||||
hash: photo ? (primaryFile(photo).Hash ?? null) : null,
|
||||
video: photo ? isVideo(photo) : false,
|
||||
idx: i
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Scroll the focused tile into the centre of the strip whenever
|
||||
// `focused` changes. We key the scroll on `data-uid` so the lookup
|
||||
// survives slice re-renders.
|
||||
let stripEl: HTMLDivElement | null = $state(null);
|
||||
$effect(() => {
|
||||
const uid = focused;
|
||||
if (!uid || !stripEl) return;
|
||||
// Defer until after the slice re-renders — without this, the
|
||||
// querySelector on the freshly added tile element returns null.
|
||||
queueMicrotask(() => {
|
||||
const el = stripEl?.querySelector<HTMLElement>(`[data-strip-uid="${uid}"]`);
|
||||
el?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifier-aware click: shift extends the range from the anchor, ⌘/Ctrl
|
||||
* toggles in/out of the multi-selection, plain click reduces to this
|
||||
* tile. Same semantics as the grid's PhotoTile + gridKeyNav click
|
||||
* handler, so the carousel reads as a continuation of the grid rather
|
||||
* than a separate surface.
|
||||
*/
|
||||
function onTileClick(e: MouseEvent, uid: string) {
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
setFocused(uid);
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
return;
|
||||
}
|
||||
selectOnly(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={stripEl}
|
||||
class="flex h-20 shrink-0 items-center gap-1 overflow-x-auto overflow-y-hidden border-t border-border bg-background/80 px-2 py-1.5 backdrop-blur"
|
||||
>
|
||||
{#each slice as tile (tile.uid)}
|
||||
{@const isFocused = tile.uid === focused}
|
||||
{@const isSelected = isFocused || selection.ids.has(tile.uid)}
|
||||
<!--
|
||||
Mirror PhotoTile's selected styling so the focused tile in the
|
||||
strip reads identically to a selected tile in the grid: the
|
||||
springy scale-90, the blue ring with background offset, and
|
||||
the blue tint overlay. Keeps the visual language consistent
|
||||
as the user moves between grid and modal.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
data-strip-uid={tile.uid}
|
||||
onclick={(e) => onTileClick(e, tile.uid)}
|
||||
aria-label={`Photo ${tile.idx + 1} of ${order.length}`}
|
||||
aria-current={isFocused ? 'true' : undefined}
|
||||
class:scale-90={isSelected}
|
||||
class:ring-2={isSelected}
|
||||
class:ring-blue-500={isSelected}
|
||||
class:ring-offset-2={isSelected}
|
||||
class:ring-offset-background={isSelected}
|
||||
class:transition-[transform,box-shadow]={isSelected}
|
||||
class:duration-300={isSelected}
|
||||
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={isSelected}
|
||||
class="relative h-full aspect-square shrink-0 overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
{#if tile.hash}
|
||||
<img
|
||||
src={thumbUrl(tile.hash, 'tile_100')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{#if isSelected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if tile.video}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||
>VIDEO</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
178
web/src/lib/components/preview/PreviewModal.svelte
Normal file
178
web/src/lib/components/preview/PreviewModal.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<!--
|
||||
Full-screen preview modal. Mounted once at the layout root so every
|
||||
route (timeline, /tags, future grids) can open it via Space or a
|
||||
double-click on a tile. Reads from the global selection store:
|
||||
|
||||
- selection.focused → which photo to display
|
||||
- selection.order → walked left/right + drives PreviewCarousel
|
||||
|
||||
Layout: preview pane (left, fills) + RightSidebar (right) + action
|
||||
toolbar + thumbnail carousel along the bottom. Keyboard nav (←/→/Space/
|
||||
Esc) is owned here; gridKeyNav bails out of those keys while the modal
|
||||
is open so we don't double-handle. Action keys (X/S/U/⌘Z/etc.) continue
|
||||
to flow through gridKeyNav since they target selection state, which
|
||||
the modal shares.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { closePreview, view } from '$lib/stores/view.svelte';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import PreviewPane from './PreviewPane.svelte';
|
||||
import PreviewCarousel from './PreviewCarousel.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import { InlineLoader } from '$lib/components/feedback';
|
||||
|
||||
const focusedUid = $derived(selection.focused);
|
||||
|
||||
// Fetch the focused photo so the RightSidebar always has full
|
||||
// metadata even when the user jumped here from a list that only
|
||||
// hydrated thumbnails.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', focusedUid ?? ''],
|
||||
queryFn: () => getPhoto(focusedUid as string),
|
||||
enabled: Boolean(focusedUid) && isAuthenticated() && view.previewOpen
|
||||
}));
|
||||
|
||||
function step(delta: number, extending: boolean) {
|
||||
const order = selection.order;
|
||||
const cur = focusedUid ? order.indexOf(focusedUid) : -1;
|
||||
if (cur < 0) return;
|
||||
const next = order[cur + delta];
|
||||
if (!next) return;
|
||||
// Plain arrow drops any prior multi-selection down to the cursor —
|
||||
// otherwise the previously selected tiles keep their blue ring and
|
||||
// the carousel reads as two simultaneously selected images (the old
|
||||
// ones still in ids, plus the freshly focused one). Mirrors
|
||||
// gridKeyNav.moveFocus's contract.
|
||||
if (!extending) clearSelection();
|
||||
setFocused(next);
|
||||
if (extending) selectRange(next);
|
||||
else setAnchor(next);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Don't steal keys from inputs inside the sidebar (editable
|
||||
// metadata fields, keyword chips, etc.).
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
// stopImmediatePropagation prevents gridKeyNav's window-level
|
||||
// handler from firing afterwards — without it, closing on Space
|
||||
// would set previewOpen=false and then gridKeyNav's Space branch
|
||||
// would re-open the modal because selection.focused is still set.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
// First Esc collapses a bulk back to single-focus on its first
|
||||
// member, keeping the modal open so the user can keep working.
|
||||
// Second Esc (no bulk left) closes the modal.
|
||||
if (clearBulkToFirst()) return;
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(-1, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(1, e.shiftKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind a window-level handler only while the modal is open. The
|
||||
// `capture` phase lets us pre-empt gridKeyNav's own listener for the
|
||||
// keys we own (arrows, Esc, Space) without having to coordinate
|
||||
// listener order.
|
||||
$effect(() => {
|
||||
if (!view.previewOpen) return;
|
||||
const handler = (e: KeyboardEvent) => onKeydown(e);
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handler, { capture: true } as EventListenerOptions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={view.previewOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closePreview();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/95 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
>
|
||||
<Dialog.Title class="sr-only">Photo preview</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">
|
||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||
</Dialog.Description>
|
||||
|
||||
<!-- Top row: preview pane (fills) + sidebar (fixed width).
|
||||
BulkActionBar lives inside the main column — same shape as the
|
||||
timeline (+page.svelte) so the bar stays bounded by the
|
||||
column's width and doesn't stretch under the metadata
|
||||
sidebar. -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/80 text-foreground shadow hover:bg-background"
|
||||
onclick={closePreview}
|
||||
aria-label="Close preview"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||
</div>
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
<!-- Sidebar stays mounted across photo changes so the preview
|
||||
pane doesn't reflow on arrow-skim; contents swap between
|
||||
the metadata panel and a small loader the same way the
|
||||
timeline's right-aside does. -->
|
||||
<aside
|
||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||
>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<InlineLoader size="sm" label="Loading metadata…" />
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Bottom filmstrip across selection.order. -->
|
||||
<PreviewCarousel />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
174
web/src/lib/components/preview/PreviewPane.svelte
Normal file
174
web/src/lib/components/preview/PreviewPane.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<!--
|
||||
Inner preview surface used by the full-screen PreviewModal and the
|
||||
shareable /photo/[uid] deep-link route. Renders the focused photo or
|
||||
video, with optional prev/next chevrons that walk `order` via
|
||||
setFocused.
|
||||
|
||||
Video mounting is debounced 250 ms so arrow-skim across a stretch of
|
||||
video tiles doesn't open (and immediately cancel) range requests we'd
|
||||
throw away. Until the timer fires, the poster image stands in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
uid: string | null;
|
||||
order: string[];
|
||||
/** Hide the floating prev/next chevrons (callers that wrap their
|
||||
* own nav can disable to avoid duplication). */
|
||||
showChevrons?: boolean;
|
||||
}
|
||||
let { uid, order, showChevrons = true }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
queryFn: () => getPhoto(uid as string),
|
||||
enabled: Boolean(uid)
|
||||
}));
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
|
||||
* cache without firing a fetch, so we can resolve adjacent hashes for
|
||||
* prefetching without making the prefetch itself trigger more work. */
|
||||
function lookupCached(target: string): PpPhoto | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
|
||||
if (direct) return direct;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === target);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === target);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
|
||||
// arrow-skim feels instant. We `new Image()` rather than `<link
|
||||
// rel=preload>` because the URLs are runtime-derived and a throwaway
|
||||
// Image() reuses the browser's HTTP cache the same way.
|
||||
$effect(() => {
|
||||
if (!uid || currentIndex < 0) return;
|
||||
for (const offset of [-1, 1, -2, 2]) {
|
||||
const idx = currentIndex + offset;
|
||||
if (idx < 0 || idx >= order.length) continue;
|
||||
const photo = lookupCached(order[idx]);
|
||||
if (!photo) continue;
|
||||
const hash = primaryFile(photo).Hash;
|
||||
if (!hash) continue;
|
||||
const img = new Image();
|
||||
img.src = thumbUrl(hash, 'fit_1280');
|
||||
}
|
||||
});
|
||||
|
||||
const VIDEO_LOAD_DELAY_MS = 250;
|
||||
let armedUid = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const target = uid;
|
||||
if (!target) {
|
||||
armedUid = null;
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
armedUid = target;
|
||||
}, VIDEO_LOAD_DELAY_MS);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
|
||||
function focusAt(i: number) {
|
||||
const next = order[i];
|
||||
if (!next) return;
|
||||
setFocused(next);
|
||||
setAnchor(next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||
{#if uid === null}
|
||||
<EmptyState icon={ImageIcon} title="Select a photo to preview" />
|
||||
{:else if photoQuery.isPending}
|
||||
<InlineLoader label="Loading photo…" align="center" />
|
||||
{:else if photoQuery.isError}
|
||||
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
|
||||
{:else if photoQuery.data}
|
||||
{@const pf = primaryFile(photoQuery.data)}
|
||||
{#if showChevrons && currentIndex > 0}
|
||||
<button
|
||||
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||
onclick={() => focusAt(currentIndex - 1)}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{/if}
|
||||
{#if showChevrons && currentIndex >= 0 && currentIndex < order.length - 1}
|
||||
<button
|
||||
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||
onclick={() => focusAt(currentIndex + 1)}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isVideo(photoQuery.data) && armedUid === uid}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
{@const altText =
|
||||
photoQuery.data.OriginalName ??
|
||||
pf.Name ??
|
||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||
{#if pf.Width && pf.Height}
|
||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||
the tile_*'s square center-crop against the sharp image's
|
||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||
lands in the exact same bounding box as the sharp <img>
|
||||
beside it (object-contain semantics, but expressible on a
|
||||
positioned element). Paints from the HTTP cache the moment
|
||||
the modal opens. -->
|
||||
<img
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={altText}
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
124
web/src/lib/components/preview/VideoPlayer.svelte
Normal file
124
web/src/lib/components/preview/VideoPlayer.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<!--
|
||||
Vidstack-based video player. Wraps the native `<video>` element with a
|
||||
polished, PhotoPrism-style chrome (gradient bottom bar, large play
|
||||
overlay, hover-revealed scrubber). Element registration is dynamically
|
||||
imported so it never runs during SvelteKit's static prerender.
|
||||
|
||||
Imports are side-effecting:
|
||||
- `vidstack/player` registers <media-player> / <media-provider>
|
||||
- `vidstack/player/layouts/default` registers <media-video-layout>
|
||||
- the two CSS imports ship the dark theme and video-layout styles
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import 'vidstack/player/styles/default/theme.css';
|
||||
import 'vidstack/player/styles/default/layouts/video.css';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
poster?: string;
|
||||
title?: string;
|
||||
}
|
||||
let { src, poster, title }: Props = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
// Whichever branch is currently mounted (fallback <video> or vidstack
|
||||
// <media-player>) binds here. On destroy we reach through this ref to
|
||||
// abort the in-flight range request — see onDestroy below.
|
||||
let host = $state<HTMLElement | undefined>();
|
||||
|
||||
onMount(async () => {
|
||||
// Bundle these into the client chunk only — the modules side-effect
|
||||
// `customElements.define()` calls, which would crash under SSR.
|
||||
await import('vidstack/player');
|
||||
await import('vidstack/player/ui');
|
||||
await import('vidstack/player/layouts/default');
|
||||
ready = true;
|
||||
});
|
||||
|
||||
// Hard-abort the underlying <video> on unmount. Detaching the element
|
||||
// alone doesn't reliably release the HTTP range request — the connection
|
||||
// can linger as "Pending" long enough to starve the grid's thumbnail
|
||||
// queue. Explicitly pausing, clearing src, and calling load() tells the
|
||||
// network layer to drop the stream now.
|
||||
onDestroy(() => {
|
||||
if (!host) return;
|
||||
const v =
|
||||
host.tagName.toLowerCase() === 'video'
|
||||
? (host as HTMLVideoElement)
|
||||
: host.querySelector('video');
|
||||
if (!v) return;
|
||||
try {
|
||||
v.pause();
|
||||
v.removeAttribute('src');
|
||||
v.load();
|
||||
} catch {
|
||||
// Element may already be detached / GC'd; ignore.
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
<!--
|
||||
Note: `src` is *not* set as an attribute on <media-player>. Our backend
|
||||
URL ends in `/avc` (not `.mp4`), so Vidstack's URL-suffix sniff fails
|
||||
and it falls back to a HEAD probe — which can pick the wrong loader
|
||||
and produce decode errors. Declaring the source via a nested
|
||||
<source type="video/mp4"> tells Vidstack to use the native MP4 loader
|
||||
directly, no probe needed.
|
||||
-->
|
||||
<media-player
|
||||
bind:this={host}
|
||||
class="vds-player max-h-full max-w-full rounded-md shadow-2xl"
|
||||
title={title ?? ''}
|
||||
{poster}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
load="eager"
|
||||
posterLoad="eager"
|
||||
streamType="on-demand"
|
||||
viewType="video"
|
||||
>
|
||||
<media-provider>
|
||||
<source {src} type="video/mp4" />
|
||||
</media-provider>
|
||||
<media-video-layout></media-video-layout>
|
||||
</media-player>
|
||||
{:else}
|
||||
<!-- Fallback while the Vidstack module is loading. Keeps the poster
|
||||
visible so the transition into the full chrome isn't jarring. -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={host}
|
||||
{src}
|
||||
{poster}
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
></video>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.vds-player) {
|
||||
/* Let the player size to the video's intrinsic aspect ratio,
|
||||
capped by the host pane. Don't set width/height to 100% — that
|
||||
was stretching widescreen video into the square preview pane.
|
||||
The default vidstack layout reads the loaded media's aspect
|
||||
and sizes the box accordingly; we just clamp the upper bound
|
||||
so it can't escape its host pane. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
--media-brand: #3b82f6;
|
||||
--media-focus-ring-color: #3b82f6;
|
||||
}
|
||||
:global(.vds-player video),
|
||||
:global(.vds-player media-provider) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
26
web/src/lib/components/review/CauseBadges.svelte
Normal file
26
web/src/lib/components/review/CauseBadges.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<!--
|
||||
Per-tile cause chips inside a CauseGroupCard. The tile's primary
|
||||
cause is already implied by the group it sits in, so we render
|
||||
*every* matching cause here as a short chip so the user sees the
|
||||
full reason set at a glance. Visually intentionally tiny — these
|
||||
stack at the bottom of a thumbnail without crowding it.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { CAUSES, type CauseKey } from '$lib/services/adapters/review';
|
||||
|
||||
interface Props {
|
||||
causes: CauseKey[];
|
||||
}
|
||||
let { causes }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each causes as c (c)}
|
||||
<span
|
||||
class="rounded bg-muted/70 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
title={CAUSES[c].title}
|
||||
>
|
||||
{CAUSES[c].chip}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
85
web/src/lib/components/review/CauseGroupCard.svelte
Normal file
85
web/src/lib/components/review/CauseGroupCard.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<!--
|
||||
One review-queue cause group, rendered as a thin wrapper around
|
||||
PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
|
||||
main, BulkActionBar below, RightSidebar/BulkMetadataSidebar on the
|
||||
right) — this component just adds the per-group header + suggestion
|
||||
row above the grid, then delegates tiles to PhotoGrid so selection,
|
||||
keyboard nav, and previews work the same way they do everywhere else.
|
||||
|
||||
The Low Resolution tab opts in to PhotoTile's dimension badge so the
|
||||
user can spot under-2-MP photos without opening each tile.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchArchive } from '$lib/services/photoprism';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import { type ReviewGroup } from '$lib/services/adapters/review';
|
||||
|
||||
interface Props {
|
||||
group: ReviewGroup;
|
||||
}
|
||||
let { group }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
let busy = $state(false);
|
||||
|
||||
async function archiveAll() {
|
||||
if (busy || group.photos.length === 0) return;
|
||||
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
||||
return;
|
||||
busy = true;
|
||||
try {
|
||||
await batchArchive(group.photos.map((p) => p.UID));
|
||||
toast.success(`Archived ${group.photos.length}`);
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive all failed');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function runSuggestion() {
|
||||
// Only 'archive' suggestions are reachable through this button now —
|
||||
// the page's BulkActionBar handles per-photo / multi-select Keep.
|
||||
if (group.meta.suggestedAction === 'archive') void archiveAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
{group.meta.title}
|
||||
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Suggestion line — surfaces the likely-correct bulk action. Only
|
||||
'archive' renders a quick-button; 'manual' is text-only and
|
||||
'approve' is unused today. Per-photo Keep / Archive comes from the
|
||||
page's BulkActionBar (review section) once the user selects. -->
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span>{group.meta.suggestion}</span>
|
||||
{#if group.meta.suggestedAction === 'archive'}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={runSuggestion}
|
||||
>
|
||||
Archive all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PhotoGrid
|
||||
photos={group.photos}
|
||||
dimensionBadge={group.cause === 'low_resolution'}
|
||||
/>
|
||||
</div>
|
||||
342
web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
Normal file
342
web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
Normal file
@@ -0,0 +1,342 @@
|
||||
<!--
|
||||
Multi-select metadata panel. Mirrors mule-image's RightSidebar bulk mode:
|
||||
apply the same Note / Date / Keyword to every selected photo.
|
||||
|
||||
Apply-button-driven (not blur-on-edit) so the user controls when the
|
||||
mutation fans out — accidental focus loss won't rewrite N photos.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Calendar, Star, Tag } from 'lucide-svelte';
|
||||
import {
|
||||
buildTakenAtPatch,
|
||||
bulkSetMarks,
|
||||
isValidISODate,
|
||||
type PhotoMark,
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
interface Props {
|
||||
ids: string[];
|
||||
}
|
||||
let { ids }: Props = $props();
|
||||
|
||||
let noteDraft = $state('');
|
||||
let dateDraft = $state('');
|
||||
let keywordDraft = $state('');
|
||||
// `null` = nothing picked yet; `0` / `''` = explicit clear.
|
||||
let ratingDraft = $state<number | null>(null);
|
||||
let colorDraft = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
busy = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyNote() {
|
||||
if (busy) return;
|
||||
const value = noteDraft;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
{ Caption: value, CaptionSrc: 'manual' },
|
||||
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
|
||||
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
|
||||
)
|
||||
);
|
||||
noteDraft = '';
|
||||
}
|
||||
|
||||
const dateDraftValid = $derived(dateDraft === '' || isValidISODate(dateDraft));
|
||||
async function applyDate() {
|
||||
if (busy || !dateDraft || !isValidISODate(dateDraft)) return;
|
||||
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
|
||||
// fill the local timezone field downstream.
|
||||
const iso = `${dateDraft}T00:00:00Z`;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
`Date → ${ids.length}`,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
)
|
||||
);
|
||||
dateDraft = '';
|
||||
}
|
||||
|
||||
async function applyMarks(patch: PhotoMark, label: string) {
|
||||
if (busy) return;
|
||||
await withBusy(async () => {
|
||||
// Optimistic: patch every selected photo's mark in the local
|
||||
// cache before round-tripping. Sidecar bulk endpoint is
|
||||
// authoritative; on failure we just invalidate so the next
|
||||
// list query overrides.
|
||||
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
|
||||
const map = { ...(prev ?? {}) };
|
||||
for (const id of ids) {
|
||||
const merged: PhotoMark = { ...(map[id] ?? {}), ...patch };
|
||||
if (!merged.rating) delete merged.rating;
|
||||
if (!merged.color) delete merged.color;
|
||||
if (merged.rating == null && !merged.color) delete map[id];
|
||||
else map[id] = merged;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
try {
|
||||
await bulkSetMarks(ids, patch);
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyRating() {
|
||||
if (ratingDraft === null) return;
|
||||
const value = ratingDraft;
|
||||
await applyMarks({ rating: value }, value === 0 ? 'Cleared score' : `★ ${value}`);
|
||||
ratingDraft = null;
|
||||
}
|
||||
|
||||
async function applyColor() {
|
||||
if (colorDraft === null) return;
|
||||
const value = colorDraft;
|
||||
await applyMarks({ color: value }, value ? `Color ${value}` : 'Cleared color');
|
||||
colorDraft = null;
|
||||
}
|
||||
|
||||
async function applyKeyword() {
|
||||
if (busy) return;
|
||||
const kw = keywordDraft.trim().replace(/,/g, '');
|
||||
if (!kw) return;
|
||||
keywordDraft = '';
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
(p) => {
|
||||
const cur = (p.Details?.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (cur.includes(kw)) return {};
|
||||
const next = [...cur, kw].join(', ');
|
||||
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
|
||||
},
|
||||
`Tagged "${kw}" → ${ids.length}`,
|
||||
(p) => ({
|
||||
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function onKeywordKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
void applyKeyword();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="space-y-4 p-3 text-xs">
|
||||
<header class="border-b border-border pb-2">
|
||||
<div class="text-sm font-medium text-foreground">{ids.length} selected</div>
|
||||
<p class="mt-0.5 text-[10px] text-muted-foreground">
|
||||
Edits apply to every selected photo.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Note (Caption) -->
|
||||
<section class="space-y-1">
|
||||
<div
|
||||
class="flex items-center justify-between text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span>Note</span>
|
||||
<span class="font-normal normal-case text-muted-foreground/70">
|
||||
Overwrites each photo
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder="Add a note for all selected…"
|
||||
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={noteDraft}
|
||||
disabled={busy}
|
||||
></textarea>
|
||||
<button
|
||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={applyNote}
|
||||
>
|
||||
Apply note to {ids.length}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Date (TakenAt) -->
|
||||
<section class="space-y-1">
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<Calendar class="h-3 w-3" /> Date taken
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="YYYY-MM-DD"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
aria-invalid={!dateDraftValid}
|
||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||
bind:value={dateDraft}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || !dateDraft || !dateDraftValid}
|
||||
onclick={applyDate}
|
||||
>
|
||||
Apply date to {ids.length}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Score (rating) — pick a value with the stars, then Apply. The "Clear"
|
||||
button picks `0` so the Apply step explicitly wipes the score across
|
||||
the selection. Stored on the mule-sidecar; PhotoPrism's PUT can't
|
||||
persist these. -->
|
||||
<section class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
|
||||
<div class="flex items-center gap-0.5" role="group" aria-label="Score">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 transition-colors disabled:opacity-50"
|
||||
class:text-yellow-400={ratingDraft !== null && ratingDraft >= n}
|
||||
class:text-muted-foreground={!(ratingDraft !== null && ratingDraft >= n)}
|
||||
disabled={busy}
|
||||
onclick={() => (ratingDraft = n)}
|
||||
title={`Pick ★ ${n}`}
|
||||
aria-label={`Score ${n}`}
|
||||
>
|
||||
<Star
|
||||
class="h-4 w-4"
|
||||
fill={ratingDraft !== null && ratingDraft >= n ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
class:bg-accent={ratingDraft === 0}
|
||||
class:text-foreground={ratingDraft === 0}
|
||||
disabled={busy}
|
||||
onclick={() => (ratingDraft = 0)}
|
||||
title="Pick: clear score"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || ratingDraft === null}
|
||||
onclick={applyRating}
|
||||
>
|
||||
{#if ratingDraft === null}
|
||||
Pick a score
|
||||
{:else if ratingDraft === 0}
|
||||
Clear score on {ids.length}
|
||||
{:else}
|
||||
Apply ★ {ratingDraft} to {ids.length}
|
||||
{/if}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Colors — same pattern as Score. -->
|
||||
<section class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
{@const picked = colorDraft === c.key}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
|
||||
? c.bg
|
||||
: 'bg-transparent'}"
|
||||
aria-pressed={picked}
|
||||
disabled={busy}
|
||||
onclick={() => (colorDraft = c.key)}
|
||||
title={`Pick ${c.title}`}
|
||||
aria-label={`Color ${c.key}`}
|
||||
></button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-0.5 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
class:bg-accent={colorDraft === ''}
|
||||
class:text-foreground={colorDraft === ''}
|
||||
disabled={busy}
|
||||
onclick={() => (colorDraft = '')}
|
||||
title="Pick: clear color"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || colorDraft === null}
|
||||
onclick={applyColor}
|
||||
>
|
||||
{#if colorDraft === null}
|
||||
Pick a color
|
||||
{:else if colorDraft === ''}
|
||||
Clear color on {ids.length}
|
||||
{:else}
|
||||
Apply {colorDraft} to {ids.length}
|
||||
{/if}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Keywords (additive — merge into each photo's existing list) -->
|
||||
<section class="space-y-1">
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<Tag class="h-3 w-3" /> Add keyword
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="tag name + Enter"
|
||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={keywordDraft}
|
||||
disabled={busy}
|
||||
onkeydown={onKeywordKeydown}
|
||||
/>
|
||||
<button
|
||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || !keywordDraft.trim()}
|
||||
onclick={applyKeyword}
|
||||
>
|
||||
Add to {ids.length}
|
||||
</button>
|
||||
<p class="text-[10px] text-muted-foreground/80">
|
||||
Adds to existing keywords; doesn't replace them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if busy}
|
||||
<div class="text-[10px] text-muted-foreground">Applying…</div>
|
||||
{/if}
|
||||
</aside>
|
||||
679
web/src/lib/components/sidebar/RightSidebar.svelte
Normal file
679
web/src/lib/components/sidebar/RightSidebar.svelte
Normal file
@@ -0,0 +1,679 @@
|
||||
<!--
|
||||
Metadata sidebar — compact, icon-led layout drawing from Apple Photos
|
||||
(slim row stack, mini-map link), Lightroom (collapsible IPTC + EXIF
|
||||
sections), and Immich (icon + value pairs). Editable fields are inline:
|
||||
click → type → blur to save. Mutations deep-merge through PhotoPrism's
|
||||
PUT (Details fields need the full body).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Aperture,
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
File,
|
||||
Folder,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Star,
|
||||
Tag,
|
||||
Timer,
|
||||
X
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
buildTakenAtPatch,
|
||||
getAllMarks,
|
||||
isValidISODate,
|
||||
renameOnDisk,
|
||||
setMark,
|
||||
updatePhoto,
|
||||
type PhotoMark,
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { navigateToFolder } from '$lib/stores/filters.svelte';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
}
|
||||
let { photo }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let basename = $state('');
|
||||
let caption = $state('');
|
||||
let takenAt = $state('');
|
||||
let lat = $state('');
|
||||
let lng = $state('');
|
||||
let country = $state('');
|
||||
let keywords = $state<string[]>([]);
|
||||
let keywordDraft = $state('');
|
||||
let renaming = $state(false);
|
||||
|
||||
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
||||
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
||||
* basename — it preserves the directory on disk — so we mirror that
|
||||
* contract in the UI by exposing just the basename for edit. */
|
||||
function splitName(full: string): { dir: string; base: string } {
|
||||
const i = full.lastIndexOf('/');
|
||||
return i < 0 ? { dir: '', base: full } : { dir: full.slice(0, i), base: full.slice(i + 1) };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const pf = primaryFile(photo);
|
||||
basename = splitName(pf.Name ?? '').base;
|
||||
caption = photo.Caption ?? '';
|
||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||
lat = photo.Lat ? String(photo.Lat) : '';
|
||||
lng = photo.Lng ? String(photo.Lng) : '';
|
||||
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||
const det = photo.Details ?? {};
|
||||
keywords = (det.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const patchMutation = createMutation(() => ({
|
||||
mutationFn: (patch: UpdatePhotoBody) => {
|
||||
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
|
||||
return updatePhoto(fresh, patch);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(['photo', data.UID], data);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed')
|
||||
}));
|
||||
|
||||
function commit(patch: UpdatePhotoBody) {
|
||||
patchMutation.mutate(patch);
|
||||
}
|
||||
|
||||
async function commitFilename() {
|
||||
const pf = primaryFile(photo);
|
||||
const { base: currentBase } = splitName(pf.Name ?? '');
|
||||
const next = basename.trim();
|
||||
if (!next || next === currentBase) return;
|
||||
renaming = true;
|
||||
try {
|
||||
const result = await renameOnDisk(photo.UID, next);
|
||||
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
toast.success(`Renamed → ${result.newName}`);
|
||||
pushUndo(`Renamed to ${result.newName}`, async () => {
|
||||
await renameOnDisk(photo.UID, result.oldName);
|
||||
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Rename failed');
|
||||
basename = currentBase;
|
||||
} finally {
|
||||
renaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function commitCaption() {
|
||||
if (caption === (photo.Caption ?? '')) return;
|
||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||
}
|
||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||
// are the photos with definitionally-untrusted dates, and showing the
|
||||
// row anywhere else would compete with the existing TakenAt.
|
||||
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
|
||||
// guess or file mtime), so a per-photo "needs date" heuristic would
|
||||
// silently hide the suggestion — the tab is the more reliable signal.
|
||||
const onExifStrippedTab = $derived(
|
||||
page.url.pathname === '/review' &&
|
||||
page.url.searchParams.get('tab') === 'stripped_exif'
|
||||
);
|
||||
const dateSuggestion = $derived.by(() => {
|
||||
const { fileName, path } = photoNameAndDir(photo);
|
||||
return suggestDateFromPath({
|
||||
fileName,
|
||||
originalName: photo.OriginalName,
|
||||
path
|
||||
});
|
||||
});
|
||||
const showDateSuggestion = $derived(
|
||||
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
|
||||
);
|
||||
function applyDateSuggestion() {
|
||||
if (!dateSuggestion) return;
|
||||
takenAt = dateSuggestion.iso;
|
||||
commitTakenAt();
|
||||
}
|
||||
function commitTakenAt() {
|
||||
if (!takenAt) return;
|
||||
if (!isValidISODate(takenAt)) {
|
||||
// Revert to the photo's stored date so the field doesn't sit in
|
||||
// a broken state once focus leaves it.
|
||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||
return;
|
||||
}
|
||||
// Keep the original time-of-day so date-only edits don't clobber the
|
||||
// hour. Fall back to midnight UTC when the photo has no prior TakenAt.
|
||||
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
||||
const iso = `${takenAt}${tail}`;
|
||||
if (iso === photo.TakenAt) return;
|
||||
commit(buildTakenAtPatch(iso));
|
||||
}
|
||||
function commitGps() {
|
||||
const nlat = parseFloat(lat);
|
||||
const nlng = parseFloat(lng);
|
||||
const patch: UpdatePhotoBody = {};
|
||||
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
|
||||
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
|
||||
if (Object.keys(patch).length) commit(patch);
|
||||
}
|
||||
function commitCountry() {
|
||||
const next = country.toLowerCase().slice(0, 2);
|
||||
const prev = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||
if (next === prev) return;
|
||||
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
||||
}
|
||||
|
||||
type DetailsKey = 'Keywords';
|
||||
function commitDetails(field: DetailsKey, value: string) {
|
||||
const prev = (photo.Details ?? {})[field] ?? '';
|
||||
if (value === prev) return;
|
||||
commit({ Details: { [field]: value, [`${field}Src`]: 'manual' } });
|
||||
}
|
||||
|
||||
function addKeyword() {
|
||||
const next = keywordDraft.trim().replace(/,/g, '');
|
||||
keywordDraft = '';
|
||||
if (!next || keywords.includes(next)) return;
|
||||
keywords = [...keywords, next];
|
||||
commitDetails('Keywords', keywords.join(', '));
|
||||
}
|
||||
function removeKeyword(k: string) {
|
||||
keywords = keywords.filter((x) => x !== k);
|
||||
commitDetails('Keywords', keywords.join(', '));
|
||||
}
|
||||
|
||||
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
|
||||
// silently drops these fields. One query holds the whole map; mutations
|
||||
// patch the cache optimistically and PUT to the sidecar.
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
function patchMarksCache(uid: string, next: PhotoMark | null) {
|
||||
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
|
||||
const map = { ...(prev ?? {}) };
|
||||
if (!next || (next.rating == null && !next.color)) delete map[uid];
|
||||
else map[uid] = next;
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
async function applyMark(patch: PhotoMark) {
|
||||
const prevMap = qc.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||
const prev = prevMap[photo.UID] ?? {};
|
||||
const optimistic: PhotoMark = { ...prev, ...patch };
|
||||
// Strip zero/empty so the cache matches what the sidecar persists.
|
||||
if (!optimistic.rating) delete optimistic.rating;
|
||||
if (!optimistic.color) delete optimistic.color;
|
||||
patchMarksCache(photo.UID, optimistic);
|
||||
try {
|
||||
const saved = await setMark(photo.UID, patch);
|
||||
patchMarksCache(photo.UID, saved);
|
||||
} catch (err) {
|
||||
// Rollback on failure.
|
||||
patchMarksCache(photo.UID, prev);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Click-to-toggle: clicking the same star clears, clicking a higher star
|
||||
* sets to that value. Mirrors mule-image's single-photo rating row. */
|
||||
function setRating(next: number) {
|
||||
const value = currentRating === next ? 0 : next;
|
||||
if (value === currentRating) return;
|
||||
void applyMark({ rating: value });
|
||||
}
|
||||
|
||||
/** Click-to-toggle: clicking the current color clears it; clicking a
|
||||
* different swatch swaps. */
|
||||
function setColor(next: string) {
|
||||
const value = currentColor === next ? '' : next;
|
||||
if (value === currentColor) return;
|
||||
void applyMark({ color: value });
|
||||
}
|
||||
|
||||
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
|
||||
const currentRating = $derived(photoMark.rating ?? 0);
|
||||
const currentColor = $derived(photoMark.color ?? '');
|
||||
|
||||
const pf = $derived(primaryFile(photo));
|
||||
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||
const sizeStr = $derived(
|
||||
pf.Size
|
||||
? pf.Size > 1_000_000
|
||||
? `${(pf.Size / 1_000_000).toFixed(1)} MB`
|
||||
: `${(pf.Size / 1024).toFixed(0)} KB`
|
||||
: '—'
|
||||
);
|
||||
const cameraStr = $derived(formatCameraLens(photo.Camera));
|
||||
const lensStr = $derived(formatCameraLens(photo.Lens));
|
||||
const exposureParts = $derived(formatExposureParts(photo));
|
||||
const placeLabel = $derived(
|
||||
photo.Place?.PlaceLabel && photo.Place.PlaceLabel !== 'Unknown'
|
||||
? photo.Place.PlaceLabel
|
||||
: photo.Country && photo.Country !== 'zz'
|
||||
? photo.Country.toUpperCase()
|
||||
: ''
|
||||
);
|
||||
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
|
||||
if (!c) return '';
|
||||
const make = c.Make ?? '';
|
||||
const model = c.Model ?? c.Name ?? '';
|
||||
const joined = `${make} ${model}`.trim();
|
||||
return joined && joined !== 'Unknown' ? joined : '';
|
||||
}
|
||||
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
||||
return {
|
||||
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
||||
fnum: p.FNumber ? `f/${p.FNumber}` : '',
|
||||
focal: p.FocalLength ? `${p.FocalLength}mm` : '',
|
||||
exp: p.Exposure ?? ''
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
||||
<!-- Compact info rows. Filename / folder lead the stack as icon-led
|
||||
rows so they read in the same rhythm as the date / place / camera
|
||||
rows below. The sidecar's rename endpoint only accepts a bare
|
||||
basename and preserves the directory on disk, so the basename row
|
||||
is editable while the folder row stays read-only. -->
|
||||
<dl class="space-y-1">
|
||||
<!-- Filename (editable) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<File class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
bind:value={basename}
|
||||
disabled={renaming}
|
||||
onblur={commitFilename}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
||||
/>
|
||||
{#if renaming}
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Taken at -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="YYYY-MM-DD"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
aria-invalid={!takenAtValid}
|
||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||
bind:value={takenAt}
|
||||
onblur={commitTakenAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date suggestion derived from filename / folder signals. Only
|
||||
shown on the EXIF Stripped review tab; amber styling marks
|
||||
it as unconfirmed. `(estimated day)` hint appears when the
|
||||
day was synthesised because only Y-M was available — same
|
||||
row, just so the user knows that part is fabricated. Apply
|
||||
writes the value into the date input above and commits as
|
||||
a manual TakenAt edit. -->
|
||||
{#if showDateSuggestion && dateSuggestion}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
|
||||
>
|
||||
<Folder class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
|
||||
{#if dateSuggestion.source === 'path-ym-default-day'}
|
||||
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
|
||||
onclick={applyDateSuggestion}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
|
||||
mirrors the input padding on filename / date so the read-only
|
||||
text starts at the same x-offset as the editable rows above —
|
||||
otherwise spans hug the icon while inputs sit 4px in. Root-level
|
||||
files render as `/` so the row never disappears. The arrow-up-
|
||||
right icon navigates to the timeline filtered by this folder
|
||||
with the photo pre-focused. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
|
||||
{folderLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
void navigateToFolder(dirPath || '/', {
|
||||
focusUid: photo.UID,
|
||||
focusTakenAt: photo.TakenAt ?? null
|
||||
})}
|
||||
title="Open folder in timeline"
|
||||
aria-label="Open folder in timeline"
|
||||
>
|
||||
<ArrowUpRight class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{dims}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- File size -->
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{sizeStr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location (read-only label + open-on-map icon). The arrow-up-
|
||||
right icon flies the in-app map to the photo's coordinates at
|
||||
zoom 17 (close enough for the photo's marker to be its own,
|
||||
out of any cluster). Hidden when the photo has no
|
||||
coordinates. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{placeLabel || 'No location'}
|
||||
</span>
|
||||
{#if photo.Lat && photo.Lng}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
void goto(
|
||||
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
|
||||
)}
|
||||
title="Open on map"
|
||||
aria-label="Open on map"
|
||||
>
|
||||
<MapIcon class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
|
||||
under one collapsible section. Note (PhotoPrism's Caption field,
|
||||
labelled here to match mule-image's nomenclature) sits at the top
|
||||
of the group since it's the most-edited per-photo field. Score +
|
||||
color are stored on the mule-sidecar (PhotoPrism's PUT can't
|
||||
persist them); keywords live on Details; auto-labels come from
|
||||
PhotoPrism's TF classifier and are read-only. Open by default
|
||||
since these are the culling marks the user reaches for first. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
open={getMetadataSectionOpen('tags', true)}
|
||||
ontoggle={(e) => setMetadataSection('tags', e.currentTarget.open)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Tag class="h-3 w-3" /> Tags
|
||||
</span>
|
||||
</summary>
|
||||
<div class="space-y-2 p-2 pt-1">
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="Add a note…"
|
||||
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={caption}
|
||||
onblur={commitCaption}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
|
||||
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 transition-colors disabled:opacity-50"
|
||||
class:text-yellow-400={currentRating >= n}
|
||||
class:text-muted-foreground={currentRating < n}
|
||||
onclick={() => setRating(n)}
|
||||
title={`Rate ${n}`}
|
||||
aria-label={`Rate ${n}`}
|
||||
>
|
||||
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
{@const picked = currentColor === c.key}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
|
||||
? c.bg
|
||||
: 'bg-transparent'}"
|
||||
aria-pressed={picked}
|
||||
onclick={() => setColor(c.key)}
|
||||
title={c.title}
|
||||
aria-label={`Color ${c.key}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keywords as chips -->
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Keywords</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each keywords as kw (kw)}
|
||||
<span
|
||||
class="inline-flex items-center gap-0.5 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px]"
|
||||
>
|
||||
{kw}
|
||||
<button
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
onclick={() => removeKeyword(kw)}
|
||||
aria-label={`Remove ${kw}`}
|
||||
>
|
||||
<X class="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+ tag"
|
||||
class="w-16 rounded border border-input bg-background px-1.5 py-0.5 text-[10px] shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={keywordDraft}
|
||||
onblur={addKeyword}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addKeyword();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
|
||||
only: editing labels requires re-indexing on PhotoPrism's
|
||||
side. The dashed border + lower contrast distinguishes them
|
||||
from the user-editable Keywords chips above. -->
|
||||
{#if (photo.Labels ?? []).length > 0}
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Labels (auto)
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)}
|
||||
{@const slug = lbl.Label?.Slug}
|
||||
{@const name = lbl.Label?.Name ?? slug ?? '(unknown)'}
|
||||
<a
|
||||
href={slug ? `/?q=${encodeURIComponent(`label:${slug}`)}` : '#'}
|
||||
class="inline-flex items-center rounded-full border border-dashed border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
title={`Source: ${lbl.Source ?? 'classifier'}`}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- GPS detail. Static default (closed); user's expand/collapse
|
||||
choice persists across photo switches via the view store.
|
||||
Avoid data-driven defaults here — they make the `open` attr
|
||||
change between photos, which fires a programmatic `toggle`
|
||||
event and would silently overwrite the user's preference. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
open={getMetadataSectionOpen('gps', false)}
|
||||
ontoggle={(e) => setMetadataSection('gps', e.currentTarget.open)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
GPS
|
||||
</summary>
|
||||
<div class="grid grid-cols-3 gap-1 p-2 pt-1">
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">Lat</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={lat}
|
||||
onblur={commitGps}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">Lng</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={lng}
|
||||
onblur={commitGps}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[9px] text-muted-foreground">Country</span>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="2"
|
||||
placeholder="us"
|
||||
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={country}
|
||||
onblur={commitCountry}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- File metadata. Closed by default; persists once opened. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
open={getMetadataSectionOpen('file', false)}
|
||||
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ImageIcon class="h-3 w-3" /> File
|
||||
</span>
|
||||
</summary>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
||||
{#if cameraStr}
|
||||
<dt class="text-muted-foreground">Camera</dt>
|
||||
<dd class="text-foreground/80">{cameraStr}</dd>
|
||||
{/if}
|
||||
{#if lensStr && lensStr !== cameraStr}
|
||||
<dt class="text-muted-foreground">Lens</dt>
|
||||
<dd class="text-foreground/80">{lensStr}</dd>
|
||||
{/if}
|
||||
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
||||
<dt class="text-muted-foreground">Exposure</dt>
|
||||
<dd class="flex flex-wrap gap-x-2 text-foreground/80">
|
||||
{#if exposureParts.fnum}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
|
||||
</span>
|
||||
{/if}
|
||||
{#if exposureParts.exp}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
|
||||
</span>
|
||||
{/if}
|
||||
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
|
||||
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
<dt class="text-muted-foreground">Type</dt>
|
||||
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
||||
<dt class="text-muted-foreground">Hash</dt>
|
||||
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}…</dd>
|
||||
<dt class="text-muted-foreground">Indexed</dt>
|
||||
<dd class="text-foreground/80">{(photo.IndexedAt ?? '').slice(0, 10) || '—'}</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
{#if patchMutation.isPending || renaming}
|
||||
<div class="text-[10px] text-muted-foreground">Saving…</div>
|
||||
{/if}
|
||||
</aside>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user