feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
.env.photoprism.example
Normal file
69
.env.photoprism.example
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# PhotoPrism stack — example environment file.
|
||||||
|
#
|
||||||
|
# Copy to `.env.photoprism` and adjust. The compose file is invoked with
|
||||||
|
# `--env-file .env.photoprism` so this never collides with the legacy
|
||||||
|
# `.env` used by the mule-image stack.
|
||||||
|
#
|
||||||
|
# docker compose --env-file .env.photoprism -f docker-compose.photoprism.yml up -d
|
||||||
|
|
||||||
|
|
||||||
|
# ── REQUIRED ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Host path to your photo library. PhotoPrism reads this in place and
|
||||||
|
# (post-M2) writes EXIF backwrites next to originals. Same path the legacy
|
||||||
|
# mule-image backend used.
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Host port for direct PhotoPrism UI access during M0–M3. Moves behind a
|
||||||
|
# Caddy reverse proxy at M4; keep this open through then for debugging.
|
||||||
|
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. M0 = "ro" (safe initial validation). Flip to "rw" at M2
|
||||||
|
# when the right-sidebar enables EXIF backwrite. Set in lockstep with
|
||||||
|
# PP_READONLY below.
|
||||||
|
PP_ORIGINALS_MODE=ro
|
||||||
|
PP_READONLY=true
|
||||||
|
|
||||||
|
# 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; per plan, OIDC_REGISTER=true auto-creates
|
||||||
|
# accounts at role `user`.
|
||||||
|
|
||||||
|
# OIDC_PROVIDER_NAME=Authentik
|
||||||
|
# OIDC_ISSUER_URL=https://auth.example.com/application/o/photoprism/
|
||||||
|
# OIDC_CLIENT_ID=...
|
||||||
|
# OIDC_CLIENT_SECRET=...
|
||||||
|
# OIDC_REDIRECT_URI=http://localhost:2342/api/v1/oidc/redirect
|
||||||
|
# OIDC_SCOPES=openid profile email
|
||||||
|
# OIDC_REGISTER=true
|
||||||
|
# OIDC_ROLE=user
|
||||||
|
|
||||||
|
|
||||||
|
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PP_LOG_LEVEL=info
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -37,6 +37,7 @@ dist-ssr/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.photoprism
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
@@ -61,6 +62,16 @@ build/
|
|||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# PhotoPrism state (sidecars, cache, thumbs, db backups) — regenerable.
|
||||||
|
/pp/storage/
|
||||||
|
/pp/import/
|
||||||
|
|
||||||
|
# Local-dev sample photo library used for M0 PhotoPrism validation.
|
||||||
|
/photos-sample/
|
||||||
|
|
||||||
|
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
||||||
|
/sidecar/data/
|
||||||
|
|
||||||
# Photos (for development)
|
# Photos (for development)
|
||||||
/photos/
|
/photos/
|
||||||
|
|
||||||
|
|||||||
28
docker-compose.photoprism.podman.yml
Normal file
28
docker-compose.photoprism.podman.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Podman-rootless overlay for the PhotoPrism stack.
|
||||||
|
#
|
||||||
|
# Apply alongside the base compose file:
|
||||||
|
# podman-compose --env-file .env.photoprism \
|
||||||
|
# -f docker-compose.photoprism.yml \
|
||||||
|
# -f docker-compose.photoprism.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
|
||||||
127
docker-compose.photoprism.yml
Normal file
127
docker-compose.photoprism.yml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# PhotoPrism stack — replaces the legacy mule-image backend over the course of
|
||||||
|
# milestones M0–M5 (see /home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md).
|
||||||
|
#
|
||||||
|
# This compose file is intentionally separate from docker-compose.yml so the
|
||||||
|
# legacy mule-image stack keeps running side-by-side until M5 cutover, when
|
||||||
|
# data is migrated and the legacy backend is retired.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.photoprism.yml up -d
|
||||||
|
#
|
||||||
|
# M0 scope: mariadb + photoprism only. Library mounted READ-ONLY so initial
|
||||||
|
# indexing cannot mutate originals while we validate. Backwrite, sidecar
|
||||||
|
# service, web frontend, and reverse proxy land in later milestones.
|
||||||
|
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
# Fully-qualified for podman (which refuses short names by default).
|
||||||
|
# Docker resolves the same digest.
|
||||||
|
image: docker.io/library/mariadb:11
|
||||||
|
container_name: pp-mariadb
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --innodb-buffer-pool-size=512M
|
||||||
|
- --transaction-isolation=READ-COMMITTED
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --max-connections=512
|
||||||
|
- --innodb-rollback-on-timeout=OFF
|
||||||
|
- --innodb-lock-wait-timeout=120
|
||||||
|
environment:
|
||||||
|
MARIADB_AUTO_UPGRADE: "1"
|
||||||
|
MARIADB_INITDB_SKIP_TZINFO: "1"
|
||||||
|
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
|
||||||
|
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||||
|
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism}
|
||||||
|
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism}
|
||||||
|
volumes:
|
||||||
|
- 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
|
||||||
|
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:
|
||||||
|
- "${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}
|
||||||
|
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}
|
||||||
|
# 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).
|
||||||
|
# Re-enable in M2 by overriding in .env.photoprism: PP_BACKUP_DATABASE=true.
|
||||||
|
PHOTOPRISM_DISABLE_BACKUPS: "false"
|
||||||
|
PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true}
|
||||||
|
PHOTOPRISM_DISABLE_EXIFTOOL: "false"
|
||||||
|
# OIDC — set in .env.photoprism when the IdP (Authentik) is wired up.
|
||||||
|
# Empty values keep OIDC dormant; the username/password login still works.
|
||||||
|
PHOTOPRISM_OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-}
|
||||||
|
PHOTOPRISM_OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-}
|
||||||
|
PHOTOPRISM_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
PHOTOPRISM_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
PHOTOPRISM_OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
|
||||||
|
PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||||
|
PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true}
|
||||||
|
PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user}
|
||||||
|
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}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z"
|
||||||
|
- "./pp/storage:/photoprism/storage:Z"
|
||||||
|
- "./pp/import:/photoprism/import:Z"
|
||||||
|
networks: [photoprism-network]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
photoprism-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pp_mariadb_data:
|
||||||
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;
|
||||||
97
migrate/README.md
Normal file
97
migrate/README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# migrate — legacy mule-image → PhotoPrism
|
||||||
|
|
||||||
|
Two-phase migration that lifts user metadata + heap memberships out of the
|
||||||
|
legacy mule-image Postgres into PhotoPrism without touching originals.
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────┐ legacy_export.mjs ┌─────────────────────┐
|
||||||
|
│ mule-image Postgres├───────────────────────►│ snapshot.json │
|
||||||
|
└────────────────────┘ └──────────┬──────────┘
|
||||||
|
│ run.mjs
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ PUT /api/v1/photos/:uid │ (Phase A)
|
||||||
|
│ POST /api/v1/albums + adds │ (Phase B)
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why two phases
|
||||||
|
|
||||||
|
- **Phase A — metadata via PUT.** For each legacy photo, the migrator
|
||||||
|
hashes the file on disk, resolves the PhotoPrism `UID` via
|
||||||
|
`q=hash:<sha1>`, and PUTs the merged metadata onto `/api/v1/photos/:uid`.
|
||||||
|
We tried writing YAML sidecars directly first but PhotoPrism's reindex
|
||||||
|
rewrites them from its DB state — PUT is the only authoritative path.
|
||||||
|
- **Phase B — heap memberships.** Once every legacy photo has a stable
|
||||||
|
UID, the heap → Album conversion creates manual Albums and bulk-adds
|
||||||
|
the resolved UIDs via `/albums/:uid/photos`.
|
||||||
|
|
||||||
|
Both phases are idempotent. Phase A's PUT is deep-merged (top-level
|
||||||
|
spread plus `Details` shallow-merge), so re-running pulls in any new
|
||||||
|
fields from the snapshot without clobbering server-side state. Phase B
|
||||||
|
looks up heaps by title and only creates if absent.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `legacy_export.mjs` — reads from legacy mule-image Postgres
|
||||||
|
(`DATABASE_URL` env), emits `snapshot.json`. Runs on the **homecloud server**
|
||||||
|
where the legacy backend lives.
|
||||||
|
- `run.mjs` — reads a snapshot file and walks both phases against the
|
||||||
|
PhotoPrism API at `PHOTOPRISM_BASE_URL`. Hashes files on disk under
|
||||||
|
`ORIGINALS_ROOT` to resolve UIDs. Runs **wherever the new PhotoPrism is
|
||||||
|
reachable and originals are mounted** (homecloud first, dev workstation
|
||||||
|
for the smoke test).
|
||||||
|
- `example-snapshot.json` — synthetic input that exercises Phase A + B
|
||||||
|
against the local sample library. Smoke test:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||||
|
PHOTOPRISM_USER=admin \
|
||||||
|
PHOTOPRISM_PASSWORD=... \
|
||||||
|
ORIGINALS_ROOT=$(pwd)/photos-sample \
|
||||||
|
node migrate/run.mjs --snapshot migrate/example-snapshot.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--phase=sidecars` or `--phase=heaps` to run just one phase, or
|
||||||
|
`--dry-run` to log without mutating.
|
||||||
|
|
||||||
|
## Snapshot schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"photos": [
|
||||||
|
{
|
||||||
|
"filepath": "Screenshot_20260510_110411.png",
|
||||||
|
"user_title": null,
|
||||||
|
"user_notes": "Trip to Paris",
|
||||||
|
"rating": 4,
|
||||||
|
"is_picked": true,
|
||||||
|
"is_discarded": false,
|
||||||
|
"is_hidden": false,
|
||||||
|
"taken_at": "2024-06-15T14:30:00Z",
|
||||||
|
"tags": ["paris", "trip"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"heaps": [
|
||||||
|
{
|
||||||
|
"name": "Summer 2024",
|
||||||
|
"photo_filepaths": ["Screenshot_20260510_110411.png"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath` is relative to `ORIGINALS_ROOT`.
|
||||||
|
- `is_hidden` is **dropped** by the migrator per the merge plan.
|
||||||
|
- `tags` lands in `Details.Keywords` (comma-separated, `KeywordsSrc=manual`).
|
||||||
|
- Heaps reference photos by `filepath`; the migrator resolves these to
|
||||||
|
PhotoPrism UIDs after Phase A's reindex finishes.
|
||||||
|
|
||||||
|
## What's NOT migrated
|
||||||
|
|
||||||
|
- mule-image's `is_hidden` (per the planning round).
|
||||||
|
- Heap+folder sharing state (planned for the Go sidecar service).
|
||||||
|
- Undo history.
|
||||||
|
- Nextcloud per-user roots.
|
||||||
|
- The 5-star user rating (PhotoPrism's `Rating` field is server-managed in
|
||||||
|
this build; we map mule-image's `is_picked` boolean → `Favorite`).
|
||||||
36
migrate/example-snapshot.json
Normal file
36
migrate/example-snapshot.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Synthetic snapshot exercising the migrator against ./photos-sample. Run from repo root: node migrate/run.mjs --snapshot migrate/example-snapshot.json",
|
||||||
|
"photos": [
|
||||||
|
{
|
||||||
|
"filepath": "Screenshot_20260507_204050.png",
|
||||||
|
"user_title": "Paris dawn",
|
||||||
|
"user_notes": "Migration smoke test caption",
|
||||||
|
"rating": 4,
|
||||||
|
"is_picked": true,
|
||||||
|
"is_discarded": false,
|
||||||
|
"is_hidden": false,
|
||||||
|
"taken_at": "2024-06-15T07:00:00Z",
|
||||||
|
"tags": ["paris", "migration-test"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filepath": "Screenshot_20260508_000706.png",
|
||||||
|
"user_title": null,
|
||||||
|
"user_notes": "Another mule-image note",
|
||||||
|
"rating": null,
|
||||||
|
"is_picked": false,
|
||||||
|
"is_discarded": false,
|
||||||
|
"is_hidden": false,
|
||||||
|
"taken_at": "2024-07-04T18:30:00Z",
|
||||||
|
"tags": ["fireworks"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"heaps": [
|
||||||
|
{
|
||||||
|
"name": "M5 migration test",
|
||||||
|
"photo_filepaths": [
|
||||||
|
"Screenshot_20260507_204050.png",
|
||||||
|
"Screenshot_20260508_000706.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
90
migrate/legacy_export.mjs
Normal file
90
migrate/legacy_export.mjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Dumps a legacy mule-image Postgres into the snapshot JSON shape `run.mjs`
|
||||||
|
// consumes. Intended to run on the homecloud server where the legacy stack
|
||||||
|
// lives (since `DATABASE_URL` points at the in-network Postgres there).
|
||||||
|
//
|
||||||
|
// DATABASE_URL=postgres://mulita:mulita@db:5432/mulita \
|
||||||
|
// node migrate/legacy_export.mjs > snapshot.json
|
||||||
|
//
|
||||||
|
// Uses the `pg` npm package which the legacy mule-image already ships in
|
||||||
|
// its backend image; if you're running this from a separate workstation,
|
||||||
|
// `npm install pg` first.
|
||||||
|
|
||||||
|
import pg from 'pg';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.error('DATABASE_URL required');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString: DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// ── photos ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Mule-image's photos table is the source of all per-file metadata. The
|
||||||
|
// migrator only reads fields that have a clean PhotoPrism mapping; the
|
||||||
|
// rest (auto-tags, ML-derived fields, etc.) are skipped per the merge plan.
|
||||||
|
const photoRows = await client.query(`
|
||||||
|
SELECT
|
||||||
|
p.filepath,
|
||||||
|
p.user_title,
|
||||||
|
p.user_notes,
|
||||||
|
p.rating,
|
||||||
|
p.is_picked,
|
||||||
|
p.is_discarded,
|
||||||
|
p.is_hidden,
|
||||||
|
p.taken_at,
|
||||||
|
COALESCE(
|
||||||
|
array_agg(t.name) FILTER (WHERE t.name IS NOT NULL),
|
||||||
|
'{}'
|
||||||
|
) AS tags
|
||||||
|
FROM photos p
|
||||||
|
LEFT JOIN photo_tags pt ON pt.photo_id = p.id
|
||||||
|
LEFT JOIN tags t ON t.id = pt.tag_id
|
||||||
|
WHERE p.is_active IS NULL OR p.is_active = true
|
||||||
|
GROUP BY p.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── heaps + memberships ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const heapRows = await client.query(`
|
||||||
|
SELECT h.id, h.name FROM heaps h WHERE h.is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
const heapMembers = await client.query(`
|
||||||
|
SELECT hp.heap_id, p.filepath
|
||||||
|
FROM heap_photos hp
|
||||||
|
JOIN photos p ON p.id = hp.photo_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const membersByHeap = new Map();
|
||||||
|
for (const r of heapMembers.rows) {
|
||||||
|
if (!membersByHeap.has(r.heap_id)) membersByHeap.set(r.heap_id, []);
|
||||||
|
membersByHeap.get(r.heap_id).push(r.filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── assemble ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
photos: photoRows.rows.map((r) => ({
|
||||||
|
filepath: r.filepath,
|
||||||
|
user_title: r.user_title,
|
||||||
|
user_notes: r.user_notes,
|
||||||
|
rating: r.rating,
|
||||||
|
is_picked: Boolean(r.is_picked),
|
||||||
|
is_discarded: Boolean(r.is_discarded),
|
||||||
|
is_hidden: Boolean(r.is_hidden),
|
||||||
|
taken_at: r.taken_at ? new Date(r.taken_at).toISOString() : null,
|
||||||
|
tags: r.tags ?? []
|
||||||
|
})),
|
||||||
|
heaps: heapRows.rows.map((h) => ({
|
||||||
|
name: h.name,
|
||||||
|
photo_filepaths: membersByHeap.get(h.id) ?? []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(snapshot, null, 2));
|
||||||
|
await client.end();
|
||||||
257
migrate/run.mjs
Normal file
257
migrate/run.mjs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Two-phase migration: legacy snapshot → PhotoPrism (metadata + heaps).
|
||||||
|
//
|
||||||
|
// Phase A — for each legacy photo, hash the on-disk file, resolve the
|
||||||
|
// PhotoPrism UID via `q=hash:<sha1>`, then PUT the merged metadata onto
|
||||||
|
// `/api/v1/photos/:uid`. We don't write sidecars directly because
|
||||||
|
// PhotoPrism's reindex rewrites them from its DB state — PUT is the only
|
||||||
|
// authoritative path. Slower (one API call per photo) but reliable.
|
||||||
|
//
|
||||||
|
// Phase B — creates a manual PhotoPrism Album for each legacy heap and
|
||||||
|
// bulk-adds the resolved UIDs via `/albums/:uid/photos`.
|
||||||
|
//
|
||||||
|
// Both phases are idempotent. Re-running Phase A merges the same fields
|
||||||
|
// (PhotoPrism's PUT does a deep-merge for nested Details). Phase B looks
|
||||||
|
// up heaps by title and only creates if absent.
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
// ── env / cli ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PP_BASE = process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342';
|
||||||
|
const PP_USER = process.env.PHOTOPRISM_USER ?? 'admin';
|
||||||
|
const PP_PW = process.env.PHOTOPRISM_PASSWORD;
|
||||||
|
const ORIGINALS_ROOT = path.resolve(
|
||||||
|
process.env.ORIGINALS_ROOT ?? '/photoprism/originals'
|
||||||
|
);
|
||||||
|
const STORAGE_ROOT = path.resolve(
|
||||||
|
process.env.STORAGE_ROOT ?? '/photoprism/storage'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!PP_PW) {
|
||||||
|
console.error('PHOTOPRISM_PASSWORD is required');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (!args.snapshot) {
|
||||||
|
console.error('--snapshot <path> required');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const phase = args.phase ?? 'all';
|
||||||
|
if (!['sidecars', 'heaps', 'all'].includes(phase)) {
|
||||||
|
console.error(`unknown --phase=${phase} (sidecars|heaps|all)`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--snapshot') out.snapshot = argv[++i];
|
||||||
|
else if (a === '--phase') out.phase = argv[++i];
|
||||||
|
else if (a === '--dry-run') out.dryRun = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── http helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let TOKEN = null;
|
||||||
|
async function pp(method, urlPath, body) {
|
||||||
|
const init = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(TOKEN ? { 'X-Auth-Token': TOKEN } : {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (body !== undefined) init.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(new URL(urlPath, PP_BASE), init);
|
||||||
|
const text = await r.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
const msg = data?.error ?? data?.message ?? `HTTP ${r.status}`;
|
||||||
|
throw new Error(`${method} ${urlPath} → ${msg}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const data = await pp('POST', '/api/v1/session', {
|
||||||
|
username: PP_USER,
|
||||||
|
password: PP_PW
|
||||||
|
});
|
||||||
|
TOKEN = data.access_token;
|
||||||
|
console.log(`logged in as ${data.user.Name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hashing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function sha1(absPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash('sha1');
|
||||||
|
const s = createReadStream(absPath);
|
||||||
|
s.on('data', (c) => h.update(c));
|
||||||
|
s.on('end', () => resolve(h.digest('hex')));
|
||||||
|
s.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase A: PUT merged metadata per photo ───────────────────────────────────
|
||||||
|
|
||||||
|
function buildPatch(photo) {
|
||||||
|
const patch = {};
|
||||||
|
|
||||||
|
if (photo.user_title) {
|
||||||
|
patch.Title = photo.user_title;
|
||||||
|
patch.TitleSrc = 'manual';
|
||||||
|
}
|
||||||
|
if (photo.user_notes) {
|
||||||
|
patch.Caption = photo.user_notes;
|
||||||
|
patch.CaptionSrc = 'manual';
|
||||||
|
}
|
||||||
|
// mule-image's is_picked (binary "starred") → PhotoPrism Favorite.
|
||||||
|
// is_discarded → Archived. Rating + is_hidden intentionally not
|
||||||
|
// migrated per the merge plan.
|
||||||
|
if (photo.is_picked) patch.Favorite = true;
|
||||||
|
if (photo.is_discarded) patch.Archived = true;
|
||||||
|
|
||||||
|
if (photo.taken_at) {
|
||||||
|
const d = new Date(photo.taken_at);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const iso = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
||||||
|
patch.TakenAt = iso;
|
||||||
|
patch.TakenAtLocal = iso;
|
||||||
|
patch.TakenSrc = 'manual';
|
||||||
|
patch.Year = d.getUTCFullYear();
|
||||||
|
patch.Month = d.getUTCMonth() + 1;
|
||||||
|
patch.Day = d.getUTCDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(photo.tags) && photo.tags.length) {
|
||||||
|
patch.Details = {
|
||||||
|
Keywords: photo.tags.join(', '),
|
||||||
|
KeywordsSrc: 'manual'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUid(filepath) {
|
||||||
|
const abs = path.join(ORIGINALS_ROOT, filepath);
|
||||||
|
const exists = await stat(abs).then(() => true, () => false);
|
||||||
|
if (!exists) return null;
|
||||||
|
const hash = await sha1(abs);
|
||||||
|
const r = await pp(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/photos?count=1&q=${encodeURIComponent(`hash:${hash}`)}`
|
||||||
|
);
|
||||||
|
const match = Array.isArray(r) ? r[0] : null;
|
||||||
|
return match ? match.UID : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPhaseA(snapshot) {
|
||||||
|
console.log(`phase A — ${snapshot.photos.length} photos`);
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let missing = 0;
|
||||||
|
for (const p of snapshot.photos) {
|
||||||
|
const patch = buildPatch(p);
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uid = await resolveUid(p.filepath);
|
||||||
|
if (!uid) {
|
||||||
|
console.warn(` [skip] ${p.filepath} — no matching PhotoPrism photo`);
|
||||||
|
missing++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (args.dryRun) {
|
||||||
|
console.log(` [dry] ${p.filepath} → ${uid}`);
|
||||||
|
updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// PhotoPrism PUT requires the full photo body for nested Details to
|
||||||
|
// stick — fetch first, deep-merge the patch in, then PUT.
|
||||||
|
const current = await pp('GET', `/api/v1/photos/${uid}`);
|
||||||
|
const merged = { ...current, ...patch };
|
||||||
|
if (patch.Details) {
|
||||||
|
merged.Details = { ...(current.Details ?? {}), ...patch.Details };
|
||||||
|
}
|
||||||
|
await pp('PUT', `/api/v1/photos/${uid}`, merged);
|
||||||
|
console.log(` patched ${p.filepath} (${uid})`);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
console.log(`phase A — updated ${updated}, no-data ${skipped}, missing ${missing}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase B: heaps ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function findHeapByTitle(title) {
|
||||||
|
const r = await pp(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/albums?type=album&count=500&q=${encodeURIComponent(title)}`
|
||||||
|
);
|
||||||
|
const arr = Array.isArray(r) ? r : [];
|
||||||
|
return arr.find((a) => a.Title === title) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPhaseB(snapshot) {
|
||||||
|
console.log(`phase B — ${snapshot.heaps.length} heaps`);
|
||||||
|
for (const heap of snapshot.heaps) {
|
||||||
|
const existing = await findHeapByTitle(heap.name);
|
||||||
|
let album;
|
||||||
|
if (existing) {
|
||||||
|
console.log(` heap "${heap.name}" exists (${existing.UID})`);
|
||||||
|
album = existing;
|
||||||
|
} else if (args.dryRun) {
|
||||||
|
console.log(` [dry] create heap "${heap.name}"`);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
album = await pp('POST', '/api/v1/albums', {
|
||||||
|
Title: heap.name,
|
||||||
|
Type: 'album'
|
||||||
|
});
|
||||||
|
console.log(` created heap "${heap.name}" (${album.UID})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uids = [];
|
||||||
|
for (const fp of heap.photo_filepaths) {
|
||||||
|
const uid = await resolveUid(fp);
|
||||||
|
if (uid) uids.push(uid);
|
||||||
|
else console.warn(` [skip] ${fp} — no matching PhotoPrism photo`);
|
||||||
|
}
|
||||||
|
if (!uids.length) {
|
||||||
|
console.log(` heap "${heap.name}" — nothing to add`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (args.dryRun) {
|
||||||
|
console.log(` [dry] add ${uids.length} to "${heap.name}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const r = await pp('POST', `/api/v1/albums/${album.UID}/photos`, { photos: uids });
|
||||||
|
console.log(` added ${uids.length} to "${heap.name}"`);
|
||||||
|
void r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const snapshot = JSON.parse(await readFile(args.snapshot, 'utf8'));
|
||||||
|
await login();
|
||||||
|
if (phase === 'sidecars' || phase === 'all') await runPhaseA(snapshot);
|
||||||
|
if (phase === 'heaps' || phase === 'all') await runPhaseB(snapshot);
|
||||||
|
console.log('done');
|
||||||
43
sidecar/README.md
Normal file
43
sidecar/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# mule-sidecar
|
||||||
|
|
||||||
|
Auxiliary service that handles operations PhotoPrism's REST API does not expose.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Per the merge plan at `/home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md`,
|
||||||
|
a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own:
|
||||||
|
|
||||||
|
- Per-user heap sharing with pending invitations
|
||||||
|
- Folder mutations under `originals/` (create / rename / delete / move)
|
||||||
|
- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename)
|
||||||
|
|
||||||
|
The plan picks Go for stack consistency and the option to upstream features.
|
||||||
|
|
||||||
|
## What ships today
|
||||||
|
|
||||||
|
A **Node.js prototype** (`server.mjs`) covering only the **file rename** endpoint.
|
||||||
|
|
||||||
|
The decision to ship Node first is pragmatic — Go isn't installed on this dev
|
||||||
|
box and `sudo dnf install golang` needs a password. Node is already on PATH for
|
||||||
|
the SvelteKit dev server, so a single-file Node service unblocks the feature
|
||||||
|
without changing the host setup.
|
||||||
|
|
||||||
|
The endpoint contract is stable: when M4 lands the proper Go service, the
|
||||||
|
SvelteKit client keeps calling the same paths.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `POST /api/sidecar/files/:photoUid/rename` `{ "newName": "newfile.png" }`
|
||||||
|
Renames the primary file of the photo on disk under `${ORIGINALS_ROOT}`,
|
||||||
|
then triggers a PhotoPrism reindex of the parent path.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```
|
||||||
|
ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \
|
||||||
|
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||||
|
SIDECAR_PORT=8000 \
|
||||||
|
node server.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The SvelteKit dev server proxies `/api/sidecar/*` to `http://localhost:8000`.
|
||||||
845
sidecar/server.mjs
Normal file
845
sidecar/server.mjs
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
// mule-sidecar — Node.js prototype.
|
||||||
|
//
|
||||||
|
// Endpoints PhotoPrism does not expose. Today: file rename on disk.
|
||||||
|
// Future Go rewrite (per plan) will keep the same wire contract.
|
||||||
|
//
|
||||||
|
// Auth: forwards the caller's `X-Auth-Token` to PhotoPrism's session check
|
||||||
|
// before doing anything destructive. The token belongs to the end user; the
|
||||||
|
// sidecar does not hold its own credentials in this prototype.
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
import { URL, fileURLToPath } from 'node:url';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ORIGINALS_ROOT = path.resolve(
|
||||||
|
process.env.ORIGINALS_ROOT ?? '/photoprism/originals'
|
||||||
|
);
|
||||||
|
const PHOTOPRISM_BASE_URL =
|
||||||
|
process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342';
|
||||||
|
const PORT = Number(process.env.SIDECAR_PORT ?? 8000);
|
||||||
|
|
||||||
|
// JSON-backed marks store. Holds the mule-image extras PhotoPrism doesn't:
|
||||||
|
// per-photo rating (0..5) and color label (red/orange/yellow/green/'').
|
||||||
|
// Lives next to server.mjs so the Go rewrite can migrate it into MariaDB
|
||||||
|
// without touching ORIGINALS_ROOT.
|
||||||
|
const SIDECAR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MARKS_FILE = path.join(SIDECAR_DIR, 'data', 'marks.json');
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function json(res, status, body) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const c of req) chunks.push(c);
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pp(method, urlPath, token, body) {
|
||||||
|
const url = new URL(urlPath, PHOTOPRISM_BASE_URL);
|
||||||
|
const init = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'X-Auth-Token': token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (body !== undefined) init.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(url, init);
|
||||||
|
const text = await r.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
return { ok: r.ok, status: r.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSession(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
// Cheapest auth probe: list 1 photo. 401 if the token is bad.
|
||||||
|
const r = await pp('GET', '/api/v1/photos?count=1', token);
|
||||||
|
return r.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce: NewName is a bare filename (no path separators, no leading dot,
|
||||||
|
* no surprising chars). PhotoPrism's index works fine with most filename
|
||||||
|
* shapes, but we lock down the obvious dangerous ones.
|
||||||
|
*/
|
||||||
|
function sanitizeFilename(name) {
|
||||||
|
if (typeof name !== 'string') return null;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.length > 240) return null;
|
||||||
|
if (trimmed.startsWith('.')) return null;
|
||||||
|
if (/[\\/\x00]/.test(trimmed)) return null;
|
||||||
|
if (trimmed === '..' || trimmed === '.') return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marks store (rating + color label) ───────────────────────────────────────
|
||||||
|
// In-memory cache backed by an atomic JSON file write. Single-threaded Node
|
||||||
|
// means we don't need an external lock — sequential awaits serialize writes.
|
||||||
|
|
||||||
|
/** @type {Record<string, { rating?: number; color?: string; updatedAt: string }>} */
|
||||||
|
let MARKS_CACHE = null;
|
||||||
|
let marksLoadPromise = null;
|
||||||
|
|
||||||
|
async function loadMarks() {
|
||||||
|
if (MARKS_CACHE !== null) return MARKS_CACHE;
|
||||||
|
if (marksLoadPromise) return marksLoadPromise;
|
||||||
|
marksLoadPromise = (async () => {
|
||||||
|
await fs.mkdir(path.dirname(MARKS_FILE), { recursive: true });
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(MARKS_FILE, 'utf8');
|
||||||
|
MARKS_CACHE = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') MARKS_CACHE = {};
|
||||||
|
else throw err;
|
||||||
|
}
|
||||||
|
return MARKS_CACHE;
|
||||||
|
})();
|
||||||
|
return marksLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistMarks() {
|
||||||
|
const tmp = MARKS_FILE + '.tmp';
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(MARKS_CACHE ?? {}, null, 2));
|
||||||
|
await fs.rename(tmp, MARKS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize partial input. Strips unknown fields, clamps rating to 0..5,
|
||||||
|
* whitelists colors to mule-image's four-color palette. */
|
||||||
|
function sanitizeMarkPatch(patch) {
|
||||||
|
if (!patch || typeof patch !== 'object') return null;
|
||||||
|
const out = {};
|
||||||
|
if ('rating' in patch) {
|
||||||
|
const r = Math.round(Number(patch.rating));
|
||||||
|
if (!Number.isFinite(r) || r < 0 || r > 5) return null;
|
||||||
|
out.rating = r;
|
||||||
|
}
|
||||||
|
if ('color' in patch) {
|
||||||
|
const c = typeof patch.color === 'string' ? patch.color.toLowerCase() : '';
|
||||||
|
if (c !== '' && !['red', 'orange', 'yellow', 'green'].includes(c)) return null;
|
||||||
|
out.color = c;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a patch onto an existing mark, dropping zero/empty so the JSON
|
||||||
|
* stays sparse — never write `rating: 0` or `color: ''` to disk, just
|
||||||
|
* delete the field. */
|
||||||
|
function mergeMark(prev, patch) {
|
||||||
|
const merged = { ...(prev ?? {}) };
|
||||||
|
if ('rating' in patch) {
|
||||||
|
if (patch.rating > 0) merged.rating = patch.rating;
|
||||||
|
else delete merged.rating;
|
||||||
|
}
|
||||||
|
if ('color' in patch) {
|
||||||
|
if (patch.color) merged.color = patch.color;
|
||||||
|
else delete merged.color;
|
||||||
|
}
|
||||||
|
const hasAny = 'rating' in merged || 'color' in merged;
|
||||||
|
if (!hasAny) return null;
|
||||||
|
merged.updatedAt = new Date().toISOString();
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWithinOriginals(absPath) {
|
||||||
|
const real = await fs.realpath(path.dirname(absPath)).catch(() => null);
|
||||||
|
if (!real) return false;
|
||||||
|
return real === ORIGINALS_ROOT || real.startsWith(ORIGINALS_ROOT + path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a user-supplied relative path under ORIGINALS_ROOT.
|
||||||
|
* Returns the absolute resolved path on success, or null if it escapes
|
||||||
|
* the root, contains traversal sequences, or its parent is missing.
|
||||||
|
*
|
||||||
|
* `mustExist=false` is used for the *target* of a rename/create where
|
||||||
|
* the path itself doesn't yet exist; we still ensure the parent does.
|
||||||
|
*/
|
||||||
|
async function resolveUnderRoot(rel, { mustExist = true } = {}) {
|
||||||
|
if (typeof rel !== 'string') return null;
|
||||||
|
const clean = rel.replace(/^\/+/, '');
|
||||||
|
if (!clean || clean === '.' || clean.split('/').some((seg) => seg === '..' || seg === '')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const abs = path.resolve(ORIGINALS_ROOT, clean);
|
||||||
|
const parent = path.dirname(abs);
|
||||||
|
// Confirm both the abs and its parent resolve back under ORIGINALS_ROOT
|
||||||
|
// (defends against symlinks pointing out of the library).
|
||||||
|
const parentReal = await fs.realpath(parent).catch(() => null);
|
||||||
|
if (!parentReal) return null;
|
||||||
|
if (
|
||||||
|
parentReal !== ORIGINALS_ROOT &&
|
||||||
|
!parentReal.startsWith(ORIGINALS_ROOT + path.sep)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mustExist) {
|
||||||
|
const stat = await fs.stat(abs).catch(() => null);
|
||||||
|
if (!stat) return null;
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleRename(req, res, photoUid) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
if (!body) return json(res, 400, { error: 'invalid json' });
|
||||||
|
|
||||||
|
const newName = sanitizeFilename(body.newName);
|
||||||
|
if (!newName) return json(res, 400, { error: 'newName must be a plain filename' });
|
||||||
|
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
// Fetch the photo to discover the file's path on disk. The single-photo
|
||||||
|
// endpoint nests Files[0] with Root + Name; the file lookup by UID
|
||||||
|
// (/api/v1/files/:uid) doesn't exist in this build.
|
||||||
|
const photoResp = await pp('GET', `/api/v1/photos/${photoUid}`, token);
|
||||||
|
if (!photoResp.ok) return json(res, photoResp.status, { error: 'photo not found' });
|
||||||
|
|
||||||
|
const photo = photoResp.data;
|
||||||
|
const file =
|
||||||
|
(photo.Files || []).find((f) => f.Primary) || (photo.Files || [])[0];
|
||||||
|
if (!file) return json(res, 404, { error: 'no files on this photo' });
|
||||||
|
|
||||||
|
// Build the absolute current path.
|
||||||
|
const root = (file.Root && file.Root !== '/') ? file.Root : '';
|
||||||
|
const relPath = path.posix.join(root, file.Name);
|
||||||
|
const oldAbs = path.resolve(path.join(ORIGINALS_ROOT, relPath));
|
||||||
|
|
||||||
|
if (!(await ensureWithinOriginals(oldAbs))) {
|
||||||
|
return json(res, 400, { error: 'path escapes originals root' });
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(oldAbs).catch(() => null);
|
||||||
|
if (!stat || !stat.isFile()) return json(res, 404, { error: 'file missing on disk' });
|
||||||
|
|
||||||
|
const newAbs = path.resolve(path.join(path.dirname(oldAbs), newName));
|
||||||
|
if (!(await ensureWithinOriginals(newAbs))) {
|
||||||
|
return json(res, 400, { error: 'new path escapes originals root' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to clobber an existing file.
|
||||||
|
if (await fs.stat(newAbs).then(() => true, () => false)) {
|
||||||
|
return json(res, 409, { error: 'target filename already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldName = file.Name;
|
||||||
|
console.log(`[rename] ${relPath} -> ${path.posix.join(root, newName)}`);
|
||||||
|
await fs.rename(oldAbs, newAbs);
|
||||||
|
|
||||||
|
// Tell PhotoPrism to re-index the parent so the DB picks up the new path.
|
||||||
|
// `cleanup: true` removes orphan rows for the old filename.
|
||||||
|
const indexResp = await pp(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/index',
|
||||||
|
token,
|
||||||
|
{ path: root || '/', rescan: false, cleanup: true }
|
||||||
|
);
|
||||||
|
if (!indexResp.ok) {
|
||||||
|
// Best-effort: file is renamed, index will catch up eventually.
|
||||||
|
console.warn('[rename] reindex returned', indexResp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
oldRelPath: relPath,
|
||||||
|
newRelPath: path.posix.join(root, newName)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHealth(_req, res) {
|
||||||
|
json(res, 200, { ok: true, originalsRoot: ORIGINALS_ROOT });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marks handlers (rating + color) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleMarksListAll(req, res) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
const all = await loadMarks();
|
||||||
|
json(res, 200, all);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkGet(req, res, uid) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
const all = await loadMarks();
|
||||||
|
json(res, 200, all[uid] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkPut(req, res, uid) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
const patch = sanitizeMarkPatch(body);
|
||||||
|
if (patch === null) return json(res, 400, { error: 'invalid patch' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
const all = await loadMarks();
|
||||||
|
const next = mergeMark(all[uid], patch);
|
||||||
|
if (next === null) delete all[uid];
|
||||||
|
else all[uid] = next;
|
||||||
|
await persistMarks();
|
||||||
|
json(res, 200, all[uid] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkBulk(req, res) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
if (!body || !Array.isArray(body.ids)) {
|
||||||
|
return json(res, 400, { error: 'ids[] required' });
|
||||||
|
}
|
||||||
|
const patch = sanitizeMarkPatch(body.patch);
|
||||||
|
if (patch === null) return json(res, 400, { error: 'invalid patch' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
const all = await loadMarks();
|
||||||
|
const applied = {};
|
||||||
|
for (const uid of body.ids) {
|
||||||
|
if (typeof uid !== 'string' || !uid) continue;
|
||||||
|
const next = mergeMark(all[uid], patch);
|
||||||
|
if (next === null) delete all[uid];
|
||||||
|
else all[uid] = next;
|
||||||
|
applied[uid] = all[uid] ?? {};
|
||||||
|
}
|
||||||
|
await persistMarks();
|
||||||
|
json(res, 200, { count: Object.keys(applied).length, marks: applied });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folder mutations ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each endpoint operates on a relative path under ORIGINALS_ROOT, then
|
||||||
|
// triggers PhotoPrism's re-index of the parent so the DB picks up the
|
||||||
|
// change. PhotoPrism's "folder" concept is just a directory on disk —
|
||||||
|
// there's no DB-side folder entity to mutate.
|
||||||
|
|
||||||
|
async function reindex(parentRel, token) {
|
||||||
|
const r = await pp(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/index',
|
||||||
|
token,
|
||||||
|
{ path: parentRel || '/', rescan: false, cleanup: true }
|
||||||
|
);
|
||||||
|
if (!r.ok) console.warn('[folder] reindex returned', r.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderCreate(req, res) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
if (!body) return json(res, 400, { error: 'invalid json' });
|
||||||
|
if (typeof body.path !== 'string') return json(res, 400, { error: 'path required' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
const abs = await resolveUnderRoot(body.path, { mustExist: false });
|
||||||
|
if (!abs) return json(res, 400, { error: 'invalid path' });
|
||||||
|
if (await fs.stat(abs).then(() => true, () => false)) {
|
||||||
|
return json(res, 409, { error: 'already exists' });
|
||||||
|
}
|
||||||
|
await fs.mkdir(abs, { recursive: false });
|
||||||
|
const rel = path.relative(ORIGINALS_ROOT, abs);
|
||||||
|
console.log('[folder.create]', rel);
|
||||||
|
void reindex(path.posix.dirname('/' + rel), token);
|
||||||
|
json(res, 200, { ok: true, path: rel });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderRename(req, res, rel) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
if (!body) return json(res, 400, { error: 'invalid json' });
|
||||||
|
const newName = sanitizeFilename(body.newName);
|
||||||
|
if (!newName) return json(res, 400, { error: 'newName must be a plain dirname' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
const oldAbs = await resolveUnderRoot(rel);
|
||||||
|
if (!oldAbs) return json(res, 400, { error: 'invalid path' });
|
||||||
|
const stat = await fs.stat(oldAbs);
|
||||||
|
if (!stat.isDirectory()) return json(res, 400, { error: 'not a directory' });
|
||||||
|
|
||||||
|
const newAbs = path.join(path.dirname(oldAbs), newName);
|
||||||
|
if (await fs.stat(newAbs).then(() => true, () => false)) {
|
||||||
|
return json(res, 409, { error: 'target already exists' });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!newAbs.startsWith(ORIGINALS_ROOT + path.sep) &&
|
||||||
|
newAbs !== ORIGINALS_ROOT
|
||||||
|
) {
|
||||||
|
return json(res, 400, { error: 'target escapes root' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rename(oldAbs, newAbs);
|
||||||
|
const oldRel = path.relative(ORIGINALS_ROOT, oldAbs);
|
||||||
|
const newRel = path.relative(ORIGINALS_ROOT, newAbs);
|
||||||
|
console.log('[folder.rename]', oldRel, '→', newRel);
|
||||||
|
void reindex(path.posix.dirname('/' + oldRel), token);
|
||||||
|
json(res, 200, { ok: true, oldPath: oldRel, newPath: newRel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heap convert (move/copy heap photos into a folder) ─────────────────────
|
||||||
|
// PhotoPrism has no native "move all album photos into folder X" operation
|
||||||
|
// — it can't because the file-on-disk layout is its source of truth. The
|
||||||
|
// flow: list members via q=album:<UID>, fs.rename / fs.copyFile each primary
|
||||||
|
// file into the destination, optionally delete the album, then reindex
|
||||||
|
// both source and destination so PhotoPrism's DB catches up.
|
||||||
|
|
||||||
|
/** Find a non-clobbering destination for `basename` inside `destDir`. If
|
||||||
|
* `foo.jpg` exists, try `foo-1.jpg`, `foo-2.jpg`, … up to a sane cap. */
|
||||||
|
async function uniqueName(destDir, basename) {
|
||||||
|
const ext = path.extname(basename);
|
||||||
|
const stem = basename.slice(0, basename.length - ext.length);
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const candidate = i === 0 ? basename : `${stem}-${i}${ext}`;
|
||||||
|
const abs = path.join(destDir, candidate);
|
||||||
|
const exists = await fs.stat(abs).then(() => true, () => false);
|
||||||
|
if (!exists) return { abs, name: candidate };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHeapConvert(req, res, albumUid) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
if (!body) return json(res, 400, { error: 'invalid json' });
|
||||||
|
|
||||||
|
const mode = body.mode === 'copy' ? 'copy' : 'move';
|
||||||
|
const deleteHeap = mode === 'move' && body.deleteHeap === true;
|
||||||
|
const subfolderRaw = typeof body.subfolder === 'string' ? body.subfolder.trim() : '';
|
||||||
|
const subfolder = subfolderRaw ? sanitizeFilename(subfolderRaw) : null;
|
||||||
|
if (subfolderRaw && !subfolder) {
|
||||||
|
return json(res, 400, { error: 'invalid subfolder name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
// Resolve target folder. Picker passes a relative path under ORIGINALS_ROOT.
|
||||||
|
const targetAbs = await resolveUnderRoot(body.targetFolder);
|
||||||
|
if (!targetAbs) return json(res, 400, { error: 'invalid targetFolder' });
|
||||||
|
|
||||||
|
// Create the optional subfolder (idempotent).
|
||||||
|
let destAbs = targetAbs;
|
||||||
|
if (subfolder) {
|
||||||
|
destAbs = path.join(targetAbs, subfolder);
|
||||||
|
await fs.mkdir(destAbs, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the album's photos. PhotoPrism's q-DSL lets us filter by album
|
||||||
|
// UID; merged=true expands to one row per file (we need every variant
|
||||||
|
// in a stack to follow the primary). 1000 covers any realistic heap.
|
||||||
|
const listResp = await pp(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/photos?q=${encodeURIComponent(`album:${albumUid}`)}&count=1000&merged=true`,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
if (!listResp.ok) return json(res, listResp.status, { error: 'list photos failed' });
|
||||||
|
const photos = Array.isArray(listResp.data) ? listResp.data : [];
|
||||||
|
|
||||||
|
// Track source parents so we know which paths to reindex once we're done.
|
||||||
|
const sourceParents = new Set();
|
||||||
|
const errors = [];
|
||||||
|
let moved = 0;
|
||||||
|
let copied = 0;
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
const file =
|
||||||
|
(photo.Files || []).find((f) => f.Primary) || (photo.Files || [])[0];
|
||||||
|
if (!file || typeof file.Name !== 'string') {
|
||||||
|
errors.push({ uid: photo.UID, reason: 'no primary file' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// PhotoPrism Files[].Name is already originals-relative.
|
||||||
|
const srcRel = file.Name;
|
||||||
|
const srcAbs = path.resolve(path.join(ORIGINALS_ROOT, srcRel));
|
||||||
|
if (!srcAbs.startsWith(ORIGINALS_ROOT + path.sep) && srcAbs !== ORIGINALS_ROOT) {
|
||||||
|
errors.push({ uid: photo.UID, reason: 'path escapes originals' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(srcAbs).catch(() => null);
|
||||||
|
if (!stat || !stat.isFile()) {
|
||||||
|
errors.push({ uid: photo.UID, reason: 'file missing on disk' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Avoid no-op moves (file already lives in dest).
|
||||||
|
if (path.dirname(srcAbs) === destAbs) {
|
||||||
|
errors.push({ uid: photo.UID, reason: 'already in target' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = path.basename(srcAbs);
|
||||||
|
const target = await uniqueName(destAbs, basename);
|
||||||
|
if (!target) {
|
||||||
|
errors.push({ uid: photo.UID, reason: 'too many collisions' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'move') {
|
||||||
|
await fs.rename(srcAbs, target.abs);
|
||||||
|
moved += 1;
|
||||||
|
} else {
|
||||||
|
await fs.copyFile(srcAbs, target.abs);
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
sourceParents.add(path.dirname(srcRel));
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({
|
||||||
|
uid: photo.UID,
|
||||||
|
reason: err instanceof Error ? err.message : String(err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex destination + every distinct source parent so PhotoPrism's
|
||||||
|
// DB catches up to the new on-disk layout.
|
||||||
|
const destRel = path.relative(ORIGINALS_ROOT, destAbs);
|
||||||
|
const reindexPaths = new Set(sourceParents);
|
||||||
|
reindexPaths.add(destRel);
|
||||||
|
if (subfolder) reindexPaths.add(path.relative(ORIGINALS_ROOT, targetAbs));
|
||||||
|
for (const p of reindexPaths) {
|
||||||
|
void reindex(p ? '/' + p : '/', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally delete the album after a successful move. We don't gate on
|
||||||
|
// `errors.length === 0` — partial successes still warrant heap cleanup
|
||||||
|
// if the user explicitly opted in.
|
||||||
|
let heap_deleted = false;
|
||||||
|
if (deleteHeap) {
|
||||||
|
const delResp = await pp('DELETE', `/api/v1/albums/${albumUid}`, token);
|
||||||
|
heap_deleted = delResp.ok;
|
||||||
|
if (!delResp.ok) {
|
||||||
|
errors.push({ uid: albumUid, reason: `album delete: HTTP ${delResp.status}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[heap.convert] album=${albumUid} mode=${mode} moved=${moved} copied=${copied} errors=${errors.length} heap_deleted=${heap_deleted}`
|
||||||
|
);
|
||||||
|
json(res, 200, { moved, copied, errors, heap_deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderDelete(req, res, rel) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
const abs = await resolveUnderRoot(rel);
|
||||||
|
if (!abs) return json(res, 400, { error: 'invalid path' });
|
||||||
|
if (abs === ORIGINALS_ROOT) return json(res, 400, { error: 'refuse to delete root' });
|
||||||
|
const stat = await fs.stat(abs);
|
||||||
|
if (!stat.isDirectory()) return json(res, 400, { error: 'not a directory' });
|
||||||
|
|
||||||
|
// Safety: only remove if empty. Recursive rm is left to the user via shell
|
||||||
|
// until M5 lands proper file moves.
|
||||||
|
const entries = await fs.readdir(abs);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
return json(res, 409, { error: 'directory not empty' });
|
||||||
|
}
|
||||||
|
await fs.rmdir(abs);
|
||||||
|
console.log('[folder.delete]', rel);
|
||||||
|
void reindex(path.posix.dirname('/' + rel), token);
|
||||||
|
json(res, 200, { ok: true, path: rel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-folder duplicate scan ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// PhotoPrism silently drops byte-identical files at index time, so duplicates
|
||||||
|
// across folders never enter its DB. This sidecar scans ORIGINALS_ROOT
|
||||||
|
// directly: walk every file, pre-filter by size (files with unique sizes
|
||||||
|
// can't be hash-duplicates so we skip hashing them), sha1 the rest, and
|
||||||
|
// return groups of ≥2 files that share a hash.
|
||||||
|
//
|
||||||
|
// Resolution archives the unwanted copies into a `.duplicates/` quarantine
|
||||||
|
// folder under ORIGINALS_ROOT — PhotoPrism's indexer ignores dotfile dirs,
|
||||||
|
// so the moved files stop appearing in the index. Recoverable by moving
|
||||||
|
// them back to a regular subfolder + reindex.
|
||||||
|
|
||||||
|
const QUARANTINE_DIR = '.duplicates';
|
||||||
|
const SUPPORTED_EXTS = new Set([
|
||||||
|
'.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'
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function walkFiles(rootAbs) {
|
||||||
|
/** @type {{ relPath: string, absPath: string, size: number }[]} */
|
||||||
|
const out = [];
|
||||||
|
async function recurse(dir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const ent of entries) {
|
||||||
|
// Skip dotfile/dotdir (matches PhotoPrism's indexer behaviour
|
||||||
|
// + our own quarantine dir).
|
||||||
|
if (ent.name.startsWith('.')) continue;
|
||||||
|
const abs = path.join(dir, ent.name);
|
||||||
|
if (ent.isDirectory()) {
|
||||||
|
await recurse(abs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ent.isFile()) continue;
|
||||||
|
const ext = path.extname(ent.name).toLowerCase();
|
||||||
|
if (!SUPPORTED_EXTS.has(ext)) continue;
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await fs.stat(abs);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
relPath: path.relative(rootAbs, abs),
|
||||||
|
absPath: abs,
|
||||||
|
size: st.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await recurse(rootAbs);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha1File(absPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = createHash('sha1');
|
||||||
|
createReadStream(absPath)
|
||||||
|
.on('data', (chunk) => hash.update(chunk))
|
||||||
|
.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanDuplicates(token) {
|
||||||
|
const all = await walkFiles(ORIGINALS_ROOT);
|
||||||
|
|
||||||
|
// Group by size first; hashes only fire for size collisions. For a
|
||||||
|
// typical library this skips ~95% of the IO+CPU.
|
||||||
|
/** @type {Map<number, typeof all>} */
|
||||||
|
const bySize = new Map();
|
||||||
|
for (const f of all) {
|
||||||
|
const arr = bySize.get(f.size);
|
||||||
|
if (arr) arr.push(f);
|
||||||
|
else bySize.set(f.size, [f]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Map<string, { hash: string, size: number, files: typeof all }>} */
|
||||||
|
const groups = new Map();
|
||||||
|
for (const [size, group] of bySize) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
// Concurrently hash each file in this size bucket.
|
||||||
|
const hashes = await Promise.all(group.map((f) => sha1File(f.absPath)));
|
||||||
|
for (let i = 0; i < group.length; i++) {
|
||||||
|
const h = hashes[i];
|
||||||
|
const g = groups.get(h);
|
||||||
|
if (g) g.files.push(group[i]);
|
||||||
|
else groups.set(h, { hash: h, size, files: [group[i]] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to groups with ≥2 files (size collisions where hashes differed
|
||||||
|
// produce singleton entries we drop here). For each surviving group,
|
||||||
|
// ask PhotoPrism which path it has indexed — that becomes the default
|
||||||
|
// "keep" candidate so the user doesn't accidentally archive the only
|
||||||
|
// indexed copy.
|
||||||
|
const out = [];
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
if (g.files.length < 2) continue;
|
||||||
|
let indexedPath = null;
|
||||||
|
try {
|
||||||
|
const r = await pp(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/photos?q=hash:${g.hash}&count=1&merged=true`,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
if (r.ok && Array.isArray(r.data) && r.data[0]) {
|
||||||
|
const photo = r.data[0];
|
||||||
|
const primary =
|
||||||
|
(photo.Files || []).find((f) => f.Primary) ||
|
||||||
|
(photo.Files || [])[0];
|
||||||
|
if (primary?.Name) indexedPath = primary.Name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* swallow — best-effort hint */
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
hash: g.hash,
|
||||||
|
size: g.size,
|
||||||
|
indexedPath,
|
||||||
|
files: g.files.map((f) => ({ path: f.relPath, size: f.size }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Sort groups by size descending so the biggest disk wins float to top.
|
||||||
|
out.sort((a, b) => b.size * (b.files.length - 1) - a.size * (a.files.length - 1));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDupScan(req, res) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
const started = Date.now();
|
||||||
|
console.log('[dup.scan] starting walk under', ORIGINALS_ROOT);
|
||||||
|
const groups = await scanDuplicates(token);
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
console.log(`[dup.scan] ${groups.length} groups in ${ms} ms`);
|
||||||
|
json(res, 200, { groups, scannedMs: ms });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDupArchive(req, res) {
|
||||||
|
const token = req.headers['x-auth-token'];
|
||||||
|
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
|
||||||
|
const body = await readJson(req).catch(() => null);
|
||||||
|
if (!body || !Array.isArray(body.paths)) {
|
||||||
|
return json(res, 400, { error: 'paths[] required' });
|
||||||
|
}
|
||||||
|
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
|
||||||
|
|
||||||
|
const moved = [];
|
||||||
|
const errors = [];
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const targetDir = path.join(ORIGINALS_ROOT, QUARANTINE_DIR, stamp);
|
||||||
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const rel of body.paths) {
|
||||||
|
try {
|
||||||
|
const abs = await resolveUnderRoot(rel);
|
||||||
|
if (!abs) {
|
||||||
|
errors.push({ path: rel, error: 'invalid path' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Use the file's basename as the quarantine name; prepend a
|
||||||
|
// short slice of its parent dir if a collision would happen so
|
||||||
|
// two `IMG_0001.jpg` from different folders don't overwrite
|
||||||
|
// each other in the same quarantine batch.
|
||||||
|
let dest = path.join(targetDir, path.basename(abs));
|
||||||
|
let i = 1;
|
||||||
|
while (await fs.stat(dest).then(() => true, () => false)) {
|
||||||
|
const parsed = path.parse(path.basename(abs));
|
||||||
|
dest = path.join(targetDir, `${parsed.name}__${i}${parsed.ext}`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
await fs.rename(abs, dest);
|
||||||
|
moved.push({
|
||||||
|
from: rel,
|
||||||
|
to: path.relative(ORIGINALS_ROOT, dest)
|
||||||
|
});
|
||||||
|
console.log('[dup.archive]', rel, '→', path.relative(ORIGINALS_ROOT, dest));
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({
|
||||||
|
path: rel,
|
||||||
|
error: err instanceof Error ? err.message : String(err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a cleanup reindex so PhotoPrism drops any photo entries whose
|
||||||
|
// underlying file is now in the quarantine dir (out of its scan path).
|
||||||
|
if (moved.length > 0) {
|
||||||
|
void pp('POST', '/api/v1/index', token, {
|
||||||
|
path: '/',
|
||||||
|
rescan: false,
|
||||||
|
cleanup: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 200, { moved, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── router ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RENAME_RE = /^\/api\/sidecar\/files\/([^/]+)\/rename$/;
|
||||||
|
const FOLDER_CREATE_RE = /^\/api\/sidecar\/folders$/;
|
||||||
|
const FOLDER_RENAME_RE = /^\/api\/sidecar\/folders\/(.+)\/rename$/;
|
||||||
|
const FOLDER_DELETE_RE = /^\/api\/sidecar\/folders\/(.+)$/;
|
||||||
|
const MARKS_BULK_RE = /^\/api\/sidecar\/photos\/marks\/bulk$/;
|
||||||
|
const MARKS_ALL_RE = /^\/api\/sidecar\/photos\/marks$/;
|
||||||
|
const MARKS_ONE_RE = /^\/api\/sidecar\/photos\/([^/]+)\/marks$/;
|
||||||
|
const HEAP_CONVERT_RE = /^\/api\/sidecar\/albums\/([^/]+)\/convert$/;
|
||||||
|
const DUP_SCAN_RE = /^\/api\/sidecar\/duplicates\/scan$/;
|
||||||
|
const DUP_ARCHIVE_RE = /^\/api\/sidecar\/duplicates\/archive$/;
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
const p = url.pathname;
|
||||||
|
|
||||||
|
if (p === '/api/sidecar/healthz' && req.method === 'GET') {
|
||||||
|
return handleHealth(req, res);
|
||||||
|
}
|
||||||
|
// Cross-folder duplicate detection.
|
||||||
|
if (DUP_SCAN_RE.test(p) && req.method === 'GET') {
|
||||||
|
return handleDupScan(req, res);
|
||||||
|
}
|
||||||
|
if (DUP_ARCHIVE_RE.test(p) && req.method === 'POST') {
|
||||||
|
return handleDupArchive(req, res);
|
||||||
|
}
|
||||||
|
// Marks routes — bulk match first so "marks/bulk" doesn't get
|
||||||
|
// captured by the "{uid}/marks" pattern.
|
||||||
|
if (MARKS_BULK_RE.test(p) && req.method === 'POST') {
|
||||||
|
return handleMarkBulk(req, res);
|
||||||
|
}
|
||||||
|
if (MARKS_ALL_RE.test(p) && req.method === 'GET') {
|
||||||
|
return handleMarksListAll(req, res);
|
||||||
|
}
|
||||||
|
const markM = MARKS_ONE_RE.exec(p);
|
||||||
|
if (markM) {
|
||||||
|
if (req.method === 'GET') return handleMarkGet(req, res, markM[1]);
|
||||||
|
if (req.method === 'PUT') return handleMarkPut(req, res, markM[1]);
|
||||||
|
}
|
||||||
|
const fileM = RENAME_RE.exec(p);
|
||||||
|
if (fileM && req.method === 'POST') {
|
||||||
|
return handleRename(req, res, fileM[1]);
|
||||||
|
}
|
||||||
|
if (FOLDER_CREATE_RE.test(p) && req.method === 'POST') {
|
||||||
|
return handleFolderCreate(req, res);
|
||||||
|
}
|
||||||
|
const renameFolderM = FOLDER_RENAME_RE.exec(p);
|
||||||
|
if (renameFolderM && req.method === 'POST') {
|
||||||
|
return handleFolderRename(req, res, decodeURIComponent(renameFolderM[1]));
|
||||||
|
}
|
||||||
|
const deleteFolderM = FOLDER_DELETE_RE.exec(p);
|
||||||
|
if (deleteFolderM && req.method === 'DELETE') {
|
||||||
|
// Avoid matching the rename URL which has a trailing /rename.
|
||||||
|
if (!p.endsWith('/rename')) {
|
||||||
|
return handleFolderDelete(req, res, decodeURIComponent(deleteFolderM[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const heapConvertM = HEAP_CONVERT_RE.exec(p);
|
||||||
|
if (heapConvertM && req.method === 'POST') {
|
||||||
|
return handleHeapConvert(req, res, heapConvertM[1]);
|
||||||
|
}
|
||||||
|
json(res, 404, { error: 'no route' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(
|
||||||
|
`mule-sidecar listening on http://127.0.0.1:${PORT} (originals=${ORIGINALS_ROOT})`
|
||||||
|
);
|
||||||
|
});
|
||||||
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"
|
||||||
|
}
|
||||||
2621
web/package-lock.json
generated
Normal file
2621
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
web/package.json
Normal file
39
web/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
web/src/app.css
Normal file
87
web/src/app.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
@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));
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {};
|
||||||
14
web/src/app.html
Normal file
14
web/src/app.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!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" />
|
||||||
|
<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>
|
||||||
501
web/src/lib/actions/gridKeyNav.ts
Normal file
501
web/src/lib/actions/gridKeyNav.ts
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { batchEdit } from '$lib/services/batch';
|
||||||
|
import { patchTargets } from '$lib/services/bulk';
|
||||||
|
import {
|
||||||
|
addToHeap,
|
||||||
|
likePhoto,
|
||||||
|
removeFromHeap,
|
||||||
|
unlikePhoto,
|
||||||
|
type PpAlbum
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { queryClient } from '$lib/queryClient';
|
||||||
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
|
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
||||||
|
import {
|
||||||
|
clearSelection,
|
||||||
|
indexOf,
|
||||||
|
selectRange,
|
||||||
|
selection,
|
||||||
|
setAnchor,
|
||||||
|
setFocused,
|
||||||
|
toggle
|
||||||
|
} from '$lib/stores/selection.svelte';
|
||||||
|
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
|
import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, f favorite-toggle, s + (1–9) add to
|
||||||
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
|
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
||||||
|
* 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. preview overlay uid (when open) — applies to the visible preview
|
||||||
|
* photo even if the grid still shows a stale selection
|
||||||
|
* 2. multi-selection set
|
||||||
|
* 3. 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];
|
||||||
|
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. Preview wins, then multi, then focused. */
|
||||||
|
function cullTargets(): string[] {
|
||||||
|
if (preview.uid) return [preview.uid];
|
||||||
|
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||||
|
if (selection.focused) return [selection.focused];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a photo's current cached state without forcing a refetch.
|
||||||
|
* Walks every `['photos', …]` cache entry first, then the per-photo
|
||||||
|
* cache. Lets `x` decide "archive vs restore" based on the actual current
|
||||||
|
* state instead of always sending Archived=true.
|
||||||
|
*
|
||||||
|
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
|
||||||
|
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
|
||||||
|
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
|
||||||
|
* scroll. Walk both — assuming a flat array on the timeline cache used
|
||||||
|
* to throw `list.find is not a function` and abort the F/X handlers. */
|
||||||
|
function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||||
|
const lists = queryClient.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 queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
await patchTargets(
|
||||||
|
ids,
|
||||||
|
{ Archived: target },
|
||||||
|
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
||||||
|
(p) => ({ Archived: p.Archived ?? false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||||
|
* target's cached `Favorite` to decide direction so a mixed selection
|
||||||
|
* resolves to "make them all favorited" when the first isn't, mirroring
|
||||||
|
* the way `toggleArchive('toggle')` works. */
|
||||||
|
async function toggleFavoriteOnTargets() {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to favorite', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = cachedPhoto(ids[0]);
|
||||||
|
const next = !(first?.Favorite ?? false);
|
||||||
|
const { updated, errors } = await batchEdit(ids, (id) =>
|
||||||
|
next ? likePhoto(id) : unlikePhoto(id)
|
||||||
|
);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
for (const id of ids) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||||
|
}
|
||||||
|
const verb = next ? 'Favorited' : 'Unfavorited';
|
||||||
|
if (errors.length) {
|
||||||
|
// Surface the actual first error message — silent failures here are
|
||||||
|
// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
|
||||||
|
toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
|
||||||
|
description: errors[0].message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`${verb} ${ids.length}`);
|
||||||
|
pushUndo(`${verb} ${ids.length}`, async () => {
|
||||||
|
await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
for (const id of ids) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
await addToHeap(heap.UID, ids);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
toast.success(`Added ${ids.length} → ${heap.Title}`);
|
||||||
|
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
|
||||||
|
await removeFromHeap(heap.UID, ids);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewFromGrid() {
|
||||||
|
const id = selection.focused ?? selection.order[0];
|
||||||
|
if (!id) return;
|
||||||
|
openPreview(id, selection.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview() {
|
||||||
|
if (preview.uid) closePreview();
|
||||||
|
else openPreviewFromGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
const inPreview = preview.uid !== null;
|
||||||
|
|
||||||
|
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
|
||||||
|
if (!inPreview) {
|
||||||
|
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':
|
||||||
|
clearSelection();
|
||||||
|
setFocused(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode-aware shortcuts (work in grid AND preview) ──────────────────
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
togglePreview();
|
||||||
|
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 && !inPreview) {
|
||||||
|
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 && !inPreview) {
|
||||||
|
e.preventDefault();
|
||||||
|
for (const id of selection.order) selection.ids.add(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'x':
|
||||||
|
case 'X':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleArchive('toggle');
|
||||||
|
return;
|
||||||
|
case 'u':
|
||||||
|
case 'U':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleArchive('restore');
|
||||||
|
return;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFavoriteOnTargets();
|
||||||
|
return;
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
if (meta || shift) return;
|
||||||
|
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||||
|
// otherwise we fall back to the currently-viewed heap.
|
||||||
|
e.preventDefault();
|
||||||
|
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;
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectRange(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
} else if (e.metaKey || e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
} else if (selection.ids.size > 0) {
|
||||||
|
// When a multi-selection is active, a plain click reduces it to
|
||||||
|
// just this tile (matches mule-image's "selection mode" behaviour).
|
||||||
|
e.preventDefault();
|
||||||
|
selection.ids.clear();
|
||||||
|
selection.ids.add(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 filters inside `onKey` keep form-field typing and preview mode
|
||||||
|
// safe (preview owns its own Arrow/Esc).
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
70
web/src/lib/actions/nearBottom.ts
Normal file
70
web/src/lib/actions/nearBottom.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
function buildObserver(p: NearBottomParams) {
|
||||||
|
io?.disconnect();
|
||||||
|
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!current.enabled) return;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
current.onHit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
current = next;
|
||||||
|
// `enabled` and `onHit` are read live inside the callback,
|
||||||
|
// so they don't require rebuilding the observer. Root and
|
||||||
|
// preloadPx are baked in at construction.
|
||||||
|
if (rootChanged || preloadChanged) buildObserver(current);
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
173
web/src/lib/actions/visibleRange.ts
Normal file
173
web/src/lib/actions/visibleRange.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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. Mirrors PhotoPrism's pattern in
|
||||||
|
* `frontend/src/component/photo/view/cards.vue`:
|
||||||
|
*
|
||||||
|
* - One IntersectionObserver on the scroll root.
|
||||||
|
* - Observes every Nth tile (sample, not every tile, to keep observer
|
||||||
|
* overhead flat as the order grows).
|
||||||
|
* - The host registers nodes as they mount via `register(el, index)`
|
||||||
|
* and unregisters via `unregister(el)`.
|
||||||
|
* - The action calls `onChange(first, last)` whenever the visible
|
||||||
|
* window updates.
|
||||||
|
*
|
||||||
|
* Use it like:
|
||||||
|
*
|
||||||
|
* const range = $state({ first: 0, last: 0 });
|
||||||
|
* <main use:visibleRange={{
|
||||||
|
* onChange: (f, l) => { range.first = f; range.last = l; },
|
||||||
|
* sampleEvery: 5,
|
||||||
|
* }}>
|
||||||
|
* {#each photos as p, i}
|
||||||
|
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
|
||||||
|
* <Tile {p} use:registerTile={i} />
|
||||||
|
* {:else}
|
||||||
|
* <div style="height: {tileHeight}px"></div>
|
||||||
|
* {/if}
|
||||||
|
* {/each}
|
||||||
|
* </main>
|
||||||
|
*
|
||||||
|
* The returned controller exposes `register`/`unregister`/`expand` so the
|
||||||
|
* host can plumb them through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VisibleRangeParams {
|
||||||
|
onChange: (first: number, last: number) => void;
|
||||||
|
/** Sample 1 in N tiles. Higher numbers reduce observer overhead at
|
||||||
|
* the cost of resolution. PhotoPrism uses 5. */
|
||||||
|
sampleEvery?: number;
|
||||||
|
/** Optional override: trigger zone in px around the scroll root.
|
||||||
|
* Defaults to 0 (only counts the visible viewport). */
|
||||||
|
rootMargin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibleRangeController {
|
||||||
|
register(el: HTMLElement, index: number): void;
|
||||||
|
unregister(el: HTMLElement): void;
|
||||||
|
/** Force the window to include `index` (used by keyboard navigation
|
||||||
|
* before scrollIntoView so the target tile actually exists). The
|
||||||
|
* host should expand its own `first`/`last` reactive state — the
|
||||||
|
* action only tracks observed intersections. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public handle the host attaches to each tile to enrol it in the
|
||||||
|
* visibility observer. Returned by setup() rather than created here so
|
||||||
|
* it closes over the active observer instance. */
|
||||||
|
export interface VisibleRangeHandle {
|
||||||
|
register(el: HTMLElement, index: number): void;
|
||||||
|
unregister(el: HTMLElement): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
|
||||||
|
let current = params;
|
||||||
|
const indexByEl = new WeakMap<Element, number>();
|
||||||
|
const visibleIndices = new Set<number>();
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
let lastFirst = -1;
|
||||||
|
let lastLast = -1;
|
||||||
|
|
||||||
|
function rebuild() {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let dirty = false;
|
||||||
|
for (const e of entries) {
|
||||||
|
const i = indexByEl.get(e.target);
|
||||||
|
if (i === undefined) continue;
|
||||||
|
const wasIn = visibleIndices.has(i);
|
||||||
|
if (e.isIntersecting && !wasIn) {
|
||||||
|
visibleIndices.add(i);
|
||||||
|
dirty = true;
|
||||||
|
} else if (!e.isIntersecting && wasIn) {
|
||||||
|
visibleIndices.delete(i);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dirty) return;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: node,
|
||||||
|
rootMargin: current.rootMargin ?? '0px'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
if (visibleIndices.size === 0) {
|
||||||
|
// Don't emit (0, 0) — the host's last known window stays valid
|
||||||
|
// and the user is likely between layout passes. Once a sample
|
||||||
|
// tile re-enters view, the next intersection fires and we
|
||||||
|
// update for real.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first = Number.POSITIVE_INFINITY;
|
||||||
|
let last = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const i of visibleIndices) {
|
||||||
|
if (i < first) first = i;
|
||||||
|
if (i > last) last = i;
|
||||||
|
}
|
||||||
|
if (first === lastFirst && last === lastLast) return;
|
||||||
|
lastFirst = first;
|
||||||
|
lastLast = last;
|
||||||
|
current.onChange(first, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild();
|
||||||
|
|
||||||
|
const handle: VisibleRangeHandle = {
|
||||||
|
register(el, index) {
|
||||||
|
const every = current.sampleEvery ?? 5;
|
||||||
|
// Sample 1-in-N tiles. The host blindly calls register for
|
||||||
|
// every mounted tile; we only attach the observer to the
|
||||||
|
// sample subset to keep observer load O(n/N).
|
||||||
|
if (index % every !== 0) return;
|
||||||
|
indexByEl.set(el, index);
|
||||||
|
observer?.observe(el);
|
||||||
|
},
|
||||||
|
unregister(el) {
|
||||||
|
if (!indexByEl.has(el)) return;
|
||||||
|
const i = indexByEl.get(el);
|
||||||
|
if (i !== undefined) visibleIndices.delete(i);
|
||||||
|
indexByEl.delete(el);
|
||||||
|
observer?.unobserve(el);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stash the handle on the node so the host can grab it via the
|
||||||
|
// action's return. Svelte's action API only returns update/destroy,
|
||||||
|
// so we expose `getHandle` through a one-shot accessor on the host.
|
||||||
|
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: VisibleRangeParams) {
|
||||||
|
const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5);
|
||||||
|
const marginChanged = next.rootMargin !== current.rootMargin;
|
||||||
|
current = next;
|
||||||
|
if (sampleChanged || marginChanged) {
|
||||||
|
// Re-observe everything under the new config. Cheapest is
|
||||||
|
// to disconnect; the host's tile-mount effects will
|
||||||
|
// re-register on next paint when they read sampleEvery.
|
||||||
|
observer?.disconnect();
|
||||||
|
indexByEl as unknown; // no-op; entries stay valid for the rebuild
|
||||||
|
visibleIndices.clear();
|
||||||
|
lastFirst = -1;
|
||||||
|
lastLast = -1;
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
observer?.disconnect();
|
||||||
|
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the handle the action stashed on the scroll-root node. Used by
|
||||||
|
* the host's per-tile register/unregister calls. */
|
||||||
|
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; PhotoPrism 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={`Cross-folder duplicate · ${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 indexed by PhotoPrism"
|
||||||
|
>
|
||||||
|
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>
|
||||||
225
web/src/lib/components/duplicates/DuplicatesView.svelte
Normal file
225
web/src/lib/components/duplicates/DuplicatesView.svelte
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<!--
|
||||||
|
Duplicate-resolution page body. Two tabs:
|
||||||
|
|
||||||
|
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 is opt-in (button-triggered) rather than
|
||||||
|
auto-run because it's an O(disk) operation. With size pre-filtering
|
||||||
|
the scan stays fast (~250ms for 400 files in practice).
|
||||||
|
-->
|
||||||
|
<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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: DuplicateGroup[];
|
||||||
|
pending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
let { groups, pending, error }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
type Tab = 'stacks' | 'cross-folder';
|
||||||
|
let activeTab = $state<Tab>('stacks');
|
||||||
|
|
||||||
|
// Cross-folder scan is a manually-triggered query: `enabled` stays
|
||||||
|
// false until the user clicks "Scan filesystem". Subsequent clicks
|
||||||
|
// invalidate the cache so each press kicks a fresh scan.
|
||||||
|
let scanRequested = $state(false);
|
||||||
|
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||||
|
queryKey: ['duplicates-cross-folder'],
|
||||||
|
queryFn: scanCrossFolderDuplicates,
|
||||||
|
enabled: scanRequested,
|
||||||
|
staleTime: 5 * 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
function triggerScan() {
|
||||||
|
if (scanRequested && !crossQuery.isFetching) {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||||
|
} else {
|
||||||
|
scanRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (crossQuery.error) {
|
||||||
|
toast.error(
|
||||||
|
crossQuery.error instanceof Error
|
||||||
|
? crossQuery.error.message
|
||||||
|
: 'Cross-folder scan failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stackCount = $derived(groups.length);
|
||||||
|
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
|
||||||
|
|
||||||
|
// Tabs: only the visible card under the active tab should auto-focus.
|
||||||
|
// We pass `autoFocus={i === 0}` into the FIRST card of the active tab
|
||||||
|
// (and only when that tab is selected) so keyboard navigation lands
|
||||||
|
// on the right place when the user switches tabs.
|
||||||
|
function tabBtnClass(tab: Tab) {
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center gap-2 border-b-2 px-3 py-1.5 text-sm transition-colors';
|
||||||
|
return tab === activeTab
|
||||||
|
? `${base} border-foreground text-foreground`
|
||||||
|
: `${base} border-transparent text-muted-foreground hover:text-foreground`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Tab bar — sticky so it stays visible while the panel scrolls.
|
||||||
|
Same horizontal padding as the panels below so labels line up. -->
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
class="sticky top-0 z-10 flex items-center gap-1 border-b border-border bg-background px-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'stacks'}
|
||||||
|
class={tabBtnClass('stacks')}
|
||||||
|
onclick={() => (activeTab = 'stacks')}
|
||||||
|
>
|
||||||
|
Stacks
|
||||||
|
<span
|
||||||
|
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{pending ? '…' : stackCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'cross-folder'}
|
||||||
|
class={tabBtnClass('cross-folder')}
|
||||||
|
onclick={() => (activeTab = 'cross-folder')}
|
||||||
|
>
|
||||||
|
Cross-folder
|
||||||
|
<span
|
||||||
|
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{#if !scanRequested}
|
||||||
|
·
|
||||||
|
{:else if crossQuery.isFetching && !crossQuery.data}
|
||||||
|
…
|
||||||
|
{:else}
|
||||||
|
{crossCount}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stacks tab ----------------------------------------------------- -->
|
||||||
|
{#if activeTab === 'stacks'}
|
||||||
|
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 pb-6">
|
||||||
|
{#if pending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading stacks…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Could not load stacks: {error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'unknown error'}
|
||||||
|
</p>
|
||||||
|
{:else if stackCount === 0}
|
||||||
|
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>No stacks.</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you
|
||||||
|
don't have any, this tab stays empty. Cross-folder copies that
|
||||||
|
PhotoPrism rejected at index time live under the
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="underline hover:text-foreground"
|
||||||
|
onclick={() => (activeTab = 'cross-folder')}
|
||||||
|
>
|
||||||
|
Cross-folder
|
||||||
|
</button>
|
||||||
|
tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each groups as group, i (group.photo.UID)}
|
||||||
|
<StackGroupCard {group} autoFocus={i === 0} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Cross-folder tab ----------------------------------------------- -->
|
||||||
|
{#if activeTab === 'cross-folder'}
|
||||||
|
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 pb-6">
|
||||||
|
<header class="flex items-baseline justify-between gap-3">
|
||||||
|
<p class="text-[11px] text-muted-foreground">
|
||||||
|
Byte-identical files PhotoPrism 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={triggerScan}
|
||||||
|
>
|
||||||
|
{#if crossQuery.isFetching}
|
||||||
|
Scanning…
|
||||||
|
{:else if scanRequested}
|
||||||
|
Rescan filesystem
|
||||||
|
{:else}
|
||||||
|
Scan filesystem
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !scanRequested}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Click <em>Scan filesystem</em> to look for byte-identical files spread
|
||||||
|
across folders. Pre-filtered by size, so even large libraries finish
|
||||||
|
in a few seconds.
|
||||||
|
</p>
|
||||||
|
{:else if crossQuery.isFetching && !crossQuery.data}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Hashing files under originals…
|
||||||
|
</p>
|
||||||
|
{:else if crossQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Scan failed: {crossQuery.error instanceof Error
|
||||||
|
? crossQuery.error.message
|
||||||
|
: 'unknown error'}
|
||||||
|
</p>
|
||||||
|
{:else if crossCount === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
No cross-folder duplicates found.
|
||||||
|
{#if crossQuery.data}
|
||||||
|
<span class="ml-1 text-[10px] text-muted-foreground/70">
|
||||||
|
(scanned in {crossQuery.data.scannedMs} ms)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{: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}
|
||||||
|
</div>
|
||||||
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>
|
||||||
190
web/src/lib/components/layout/FolderTree.svelte
Normal file
190
web/src/lib/components/layout/FolderTree.svelte
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<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 { 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;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
nodes,
|
||||||
|
depth = 0,
|
||||||
|
onPick,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onCreateChild,
|
||||||
|
readonly = false,
|
||||||
|
selectedPath
|
||||||
|
}: 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;
|
||||||
|
}
|
||||||
|
</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). Depth × 12px keeps lines aligned with
|
||||||
|
the chevron of the previous level.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
style="padding-left: {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}
|
||||||
|
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="flex flex-1 items-center truncate px-1 text-left"
|
||||||
|
onclick={() => onPick(node.path)}
|
||||||
|
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||||
|
title={node.path}
|
||||||
|
>
|
||||||
|
<span class="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{#if !readonly}
|
||||||
|
<!-- Hover-revealed kebab. Reserves zero width when idle so the
|
||||||
|
row stays compact; expands on hover and stays visible while
|
||||||
|
the menu is open. Suppressed in readonly mode (picker). -->
|
||||||
|
<div class="mr-1">
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
226
web/src/lib/components/layout/HeapConvertDialog.svelte
Normal file
226
web/src/lib/components/layout/HeapConvertDialog.svelte
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<!--
|
||||||
|
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, Loader2 } from 'lucide-svelte';
|
||||||
|
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 } 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() {
|
||||||
|
if (!heap || !pickedPath) return;
|
||||||
|
convertMut.mutate({
|
||||||
|
uid: heap.UID,
|
||||||
|
body: {
|
||||||
|
targetFolder: 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}
|
||||||
|
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p>
|
||||||
|
{:else if (foldersQuery.data ?? []).length === 0}
|
||||||
|
<p class="px-2 py-1 text-[11px] text-muted-foreground">
|
||||||
|
No folders. Create one from the sidebar first.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<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 || 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>
|
||||||
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>
|
||||||
389
web/src/lib/components/layout/LeftSidebar.svelte
Normal file
389
web/src/lib/components/layout/LeftSidebar.svelte
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
<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 {
|
||||||
|
createFolder,
|
||||||
|
createHeap,
|
||||||
|
deleteFolder,
|
||||||
|
deleteHeap,
|
||||||
|
duplicateHeap,
|
||||||
|
heapDownloadUrl,
|
||||||
|
listFolders,
|
||||||
|
listHeaps,
|
||||||
|
renameFolder,
|
||||||
|
renameHeap,
|
||||||
|
triggerDownload,
|
||||||
|
type PpAlbum,
|
||||||
|
type PpFolder
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import {
|
||||||
|
filters,
|
||||||
|
setFolderPath,
|
||||||
|
setSection,
|
||||||
|
type Section
|
||||||
|
} from '$lib/stores/filters.svelte';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
|
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
||||||
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
|
import { Copy, Download, FolderInput, Pencil, Trash2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||||||
|
queryKey: ['heaps'],
|
||||||
|
queryFn: listHeaps,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||||
|
queryKey: ['folders'],
|
||||||
|
queryFn: listFolders,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const folderTree = $derived(
|
||||||
|
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMut = createMutation(() => ({
|
||||||
|
mutationFn: (title: string) => createHeap(title),
|
||||||
|
onSuccess: (h) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
toast.success(`Heap created: ${h.Title}`);
|
||||||
|
navigateTo('heap', h.UID);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not create heap')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renameMut = createMutation(() => ({
|
||||||
|
mutationFn: (args: { uid: string; title: string }) => renameHeap(args.uid, args.title),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['heaps'] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const deleteMut = createMutation(() => ({
|
||||||
|
mutationFn: (uid: string) => deleteHeap(uid),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
toast.success('Heap deleted');
|
||||||
|
if (filters.section === 'heap') navigateTo('all-photos');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const duplicateMut = createMutation(() => ({
|
||||||
|
mutationFn: (uid: string) => duplicateHeap(uid),
|
||||||
|
onSuccess: (copy) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
toast.success(`Duplicated → ${copy.Title}`);
|
||||||
|
navigateTo('heap', copy.UID);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Heap currently being converted (move/copy to folder). Setting this
|
||||||
|
// mounts <HeapConvertDialog>; the dialog clears it on close.
|
||||||
|
let convertingHeap = $state<PpAlbum | null>(null);
|
||||||
|
|
||||||
|
async function navigateTo(section: Section, heapUid: string | null = null) {
|
||||||
|
setSection(section, heapUid);
|
||||||
|
setFolderPath(null);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (section !== 'all-photos') params.set('section', section);
|
||||||
|
if (heapUid) params.set('heap', heapUid);
|
||||||
|
const qs = params.toString();
|
||||||
|
await goto(`/${qs ? '?' + qs : ''}`, { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFolderMut = createMutation(() => ({
|
||||||
|
mutationFn: (relPath: string) => createFolder(relPath),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
toast.success(`Folder created: ${r.path}`);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not create folder')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renameFolderMut = createMutation(() => ({
|
||||||
|
mutationFn: (args: { rel: string; newName: string }) =>
|
||||||
|
renameFolder(args.rel, args.newName),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
// If the active folder filter was on this folder, follow the rename.
|
||||||
|
if (filters.folderPath === r.oldPath) {
|
||||||
|
setFolderPath(r.newPath);
|
||||||
|
const params = new URLSearchParams({ folder: r.newPath });
|
||||||
|
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
toast.success(`Renamed: ${r.oldPath} → ${r.newPath}`);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Rename failed')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const deleteFolderMut = createMutation(() => ({
|
||||||
|
mutationFn: (rel: string) => deleteFolder(rel),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
|
||||||
|
setFolderPath(null);
|
||||||
|
void goto('/', { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
toast.success(`Folder deleted: ${r.path}`);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
||||||
|
}));
|
||||||
|
|
||||||
|
function onCreateFolder(parent: string | null = null) {
|
||||||
|
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const rel = parent ? `${parent}/${name}` : name;
|
||||||
|
createFolderMut.mutate(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenameFolder(rel: string) {
|
||||||
|
const segs = rel.split('/');
|
||||||
|
const cur = segs[segs.length - 1];
|
||||||
|
const next = prompt(`Rename folder "${rel}"`, cur)?.trim();
|
||||||
|
if (!next || next === cur) return;
|
||||||
|
renameFolderMut.mutate({ rel, newName: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteFolder(rel: string) {
|
||||||
|
if (!confirm(`Delete folder "${rel}"? Must be empty.`)) return;
|
||||||
|
deleteFolderMut.mutate(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFolder(folderPath: string) {
|
||||||
|
// Folder selection works on top of the All Photos section; clearing
|
||||||
|
// the heap/section context mirrors mule-image's "drill into folder"
|
||||||
|
// behaviour. The URL sync $effect on the timeline picks this up.
|
||||||
|
setSection('all-photos');
|
||||||
|
setFolderPath(folderPath);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('folder', folderPath);
|
||||||
|
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateHeap() {
|
||||||
|
const title = prompt('Heap name')?.trim();
|
||||||
|
if (title) createMut.mutate(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenameHeap(h: PpAlbum) {
|
||||||
|
const title = prompt('Rename heap', h.Title)?.trim();
|
||||||
|
if (title && title !== h.Title) renameMut.mutate({ uid: h.UID, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteHeap(h: PpAlbum) {
|
||||||
|
if (confirm(`Delete heap "${h.Title}"? Photos stay in the library.`)) {
|
||||||
|
deleteMut.mutate(h.UID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync section into URL when filters change (so back/forward works).
|
||||||
|
function isActive(section: Section, heapUid: string | null = null): boolean {
|
||||||
|
if (page.url.pathname !== '/') return false;
|
||||||
|
if (filters.section !== section) return false;
|
||||||
|
if (section === 'heap' && filters.heapUid !== heapUid) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single Views group — section-driven entries and route-driven entries
|
||||||
|
// mixed in display order. `kind` discriminates which click handler runs
|
||||||
|
// (sections go through `navigateTo` to seed filter state; routes are
|
||||||
|
// plain links). Archive intentionally sits at the bottom to keep it out
|
||||||
|
// of the way of the everyday-browse rows.
|
||||||
|
type ViewItem =
|
||||||
|
| { kind: 'section'; id: Section; label: string }
|
||||||
|
| { kind: 'route'; href: string; label: string };
|
||||||
|
|
||||||
|
const views: ViewItem[] = [
|
||||||
|
{ kind: 'section', id: 'all-photos', label: 'All photos' },
|
||||||
|
{ kind: 'section', id: 'favorites', label: 'Favorites' },
|
||||||
|
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
|
||||||
|
{ kind: 'route', href: '/map', label: 'Map' },
|
||||||
|
{ kind: 'route', href: '/ratings', label: 'Ratings' },
|
||||||
|
{ kind: 'route', href: '/colors', label: 'Colors' },
|
||||||
|
{ kind: 'route', href: '/tags', label: 'Tags' },
|
||||||
|
{ kind: 'section', id: 'archive', label: 'Archive' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isRouteActive(href: string): boolean {
|
||||||
|
return page.url.pathname === href;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="space-y-3">
|
||||||
|
<!-- Views — section-driven entries + route-driven entries under a
|
||||||
|
single uppercase eyebrow. Compact rows, no icons. -->
|
||||||
|
<div>
|
||||||
|
<div class="px-3 pb-1">
|
||||||
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Views
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
|
{#if v.kind === 'section'}
|
||||||
|
<button
|
||||||
|
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={isActive(v.id)}
|
||||||
|
class:text-primary-foreground={isActive(v.id)}
|
||||||
|
class:hover:bg-primary={isActive(v.id)}
|
||||||
|
onclick={() => navigateTo(v.id)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{v.label}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={v.href}
|
||||||
|
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={isRouteActive(v.href)}
|
||||||
|
class:text-primary-foreground={isRouteActive(v.href)}
|
||||||
|
class:hover:bg-primary={isRouteActive(v.href)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{v.label}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="group/header flex items-center px-3 pb-1">
|
||||||
|
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Heaps
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||||
|
onclick={onCreateHeap}
|
||||||
|
title="New heap"
|
||||||
|
aria-label="New heap"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if heapsQuery.isPending}
|
||||||
|
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||||
|
{:else if heapsQuery.isError}
|
||||||
|
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p>
|
||||||
|
{:else if (heapsQuery.data ?? []).length === 0}
|
||||||
|
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
|
{@const active = isActive('heap', heap.UID)}
|
||||||
|
<li class="group flex items-center">
|
||||||
|
<button
|
||||||
|
class="flex h-[24px] flex-1 items-center gap-2 rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
onclick={() => navigateTo('heap', heap.UID)}
|
||||||
|
ondblclick={() => onRenameHeap(heap)}
|
||||||
|
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||||
|
>
|
||||||
|
<span class="truncate">{heap.Title}</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto flex h-4 min-w-[20px] 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'}"
|
||||||
|
>
|
||||||
|
{heap.PhotoCount ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="pl-0.5">
|
||||||
|
<KebabMenu label="Heap 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={() => onRenameHeap(heap)}
|
||||||
|
>
|
||||||
|
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Rename
|
||||||
|
</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={() => duplicateMut.mutate(heap.UID)}
|
||||||
|
>
|
||||||
|
<Copy class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Duplicate
|
||||||
|
</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={() => triggerDownload(heapDownloadUrl(heap.UID))}
|
||||||
|
>
|
||||||
|
<Download class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Download as zip
|
||||||
|
</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={() => (convertingHeap = heap)}
|
||||||
|
>
|
||||||
|
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Move to folder…
|
||||||
|
</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={() => onDeleteHeap(heap)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
Delete heap…
|
||||||
|
</Item>
|
||||||
|
</KebabMenu>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="group/header flex items-center px-3 pb-1">
|
||||||
|
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Folders
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||||
|
onclick={() => onCreateFolder(null)}
|
||||||
|
title="New top-level folder"
|
||||||
|
aria-label="New top-level folder"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if foldersQuery.isPending}
|
||||||
|
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||||
|
{:else if (foldersQuery.data ?? []).length === 0}
|
||||||
|
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
||||||
|
{:else}
|
||||||
|
<FolderTree
|
||||||
|
nodes={folderTree}
|
||||||
|
onPick={pickFolder}
|
||||||
|
onRename={onRenameFolder}
|
||||||
|
onDelete={onDeleteFolder}
|
||||||
|
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if filters.folderPath}
|
||||||
|
<button
|
||||||
|
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
onclick={() => {
|
||||||
|
setFolderPath(null);
|
||||||
|
void goto('/', { keepFocus: true, noScroll: true });
|
||||||
|
}}
|
||||||
|
title="Clear folder filter"
|
||||||
|
>
|
||||||
|
<span class="truncate">✕ {filters.folderPath}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
|
||||||
73
web/src/lib/components/layout/Toolbar.svelte
Normal file
73
web/src/lib/components/layout/Toolbar.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<!--
|
||||||
|
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, tags) leave this off. */
|
||||||
|
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>
|
||||||
99
web/src/lib/components/mule/AnimatedMule.svelte
Normal file
99
web/src/lib/components/mule/AnimatedMule.svelte
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!--
|
||||||
|
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-16 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>
|
||||||
163
web/src/lib/components/preview/PreviewOverlay.svelte
Normal file
163
web/src/lib/components/preview/PreviewOverlay.svelte
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
|
import {
|
||||||
|
closePreview,
|
||||||
|
preview,
|
||||||
|
previewNext,
|
||||||
|
previewPrev
|
||||||
|
} from '$lib/stores/preview.svelte';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { setFocused } from '$lib/stores/selection.svelte';
|
||||||
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||||
|
queryKey: ['photo', preview.uid ?? ''],
|
||||||
|
queryFn: () => getPhoto(preview.uid as string),
|
||||||
|
enabled: Boolean(preview.uid)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Track the last visible uid so we can return focus to the matching
|
||||||
|
// timeline tile when the overlay closes — lets the user keep moving
|
||||||
|
// with arrow keys without re-clicking.
|
||||||
|
let lastShown: string | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
if (preview.uid !== null) {
|
||||||
|
lastShown = preview.uid;
|
||||||
|
setFocused(preview.uid);
|
||||||
|
} else if (lastShown) {
|
||||||
|
const target = lastShown;
|
||||||
|
lastShown = null;
|
||||||
|
// Wait for the overlay to unmount before grabbing focus, otherwise
|
||||||
|
// the browser swallows it as the modal element is removed.
|
||||||
|
void tick().then(() => {
|
||||||
|
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
|
||||||
|
tile?.focus({ preventScroll: false });
|
||||||
|
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard handling lives at the document level so it works regardless
|
||||||
|
// of focus location. Form fields inside the sidebar still keep their
|
||||||
|
// own arrow-key behaviour because we ignore events whose target is an
|
||||||
|
// input/textarea.
|
||||||
|
$effect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (preview.uid === null) return;
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
|
const inField = tag === 'input' || tag === 'textarea' || tag === 'select';
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
closePreview();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (inField) return;
|
||||||
|
e.preventDefault();
|
||||||
|
previewPrev();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (inField) return;
|
||||||
|
e.preventDefault();
|
||||||
|
previewNext();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent body scroll while the overlay is up.
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
if (preview.uid !== null) document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
// Clicking the dimmed area (but not the image or sidebar) closes.
|
||||||
|
if (e.target === e.currentTarget) closePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = $derived(
|
||||||
|
preview.uid ? preview.order.indexOf(preview.uid) : -1
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if preview.uid !== null}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex bg-black/80 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Photo preview"
|
||||||
|
onclick={onBackdrop}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') closePreview();
|
||||||
|
}}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<!-- Left: image area -->
|
||||||
|
<div
|
||||||
|
class="relative flex flex-1 items-center justify-center p-6"
|
||||||
|
onclick={onBackdrop}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute left-4 top-4 z-10 rounded-md bg-background/80 px-2.5 py-1.5 text-xs hover:bg-background"
|
||||||
|
onclick={closePreview}
|
||||||
|
aria-label="Close preview"
|
||||||
|
>
|
||||||
|
✕ Close
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if currentIndex > 0}
|
||||||
|
<button
|
||||||
|
class="absolute left-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||||
|
onclick={previewPrev}
|
||||||
|
aria-label="Previous photo"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if currentIndex >= 0 && currentIndex < preview.order.length - 1}
|
||||||
|
<button
|
||||||
|
class="absolute right-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||||
|
onclick={previewNext}
|
||||||
|
aria-label="Next photo"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if photoQuery.isPending}
|
||||||
|
<p class="text-sm text-white/80">Loading…</p>
|
||||||
|
{:else if photoQuery.isError}
|
||||||
|
<p class="text-sm text-red-300">Failed to load photo.</p>
|
||||||
|
{:else if photoQuery.data}
|
||||||
|
{@const pf = primaryFile(photoQuery.data)}
|
||||||
|
<img
|
||||||
|
src={thumbUrl(pf.Hash, 'fit_1920')}
|
||||||
|
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
||||||
|
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: metadata sidebar -->
|
||||||
|
<aside
|
||||||
|
class="w-[360px] shrink-0 overflow-y-auto border-l border-border bg-background p-4"
|
||||||
|
>
|
||||||
|
{#if photoQuery.data}
|
||||||
|
<RightSidebar photo={photoQuery.data} />
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading metadata…</p>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
341
web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
Normal file
341
web/src/lib/components/sidebar/BulkMetadataSidebar.svelte
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<!--
|
||||||
|
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,
|
||||||
|
type PhotoMark,
|
||||||
|
type PhotoMarksMap,
|
||||||
|
type UpdatePhotoBody
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { patchTargets } from '$lib/services/bulk';
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDate() {
|
||||||
|
if (busy || !dateDraft) return;
|
||||||
|
// datetime-local omits the timezone; treat the input as UTC (same
|
||||||
|
// convention as the single-photo sidebar) and let PhotoPrism's
|
||||||
|
// backwrite stamp the local timezone field downstream.
|
||||||
|
const iso = `${dateDraft}: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||||
|
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||||
|
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||||
|
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||||
|
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||||
|
];
|
||||||
|
|
||||||
|
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="datetime-local"
|
||||||
|
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={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}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Color label — same pattern as Score. -->
|
||||||
|
<section class="space-y-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
|
||||||
|
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||||
|
{#each COLOR_SWATCHES as c (c.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
|
||||||
|
class:ring-foreground={colorDraft === c.key}
|
||||||
|
class:ring-transparent={colorDraft !== c.key}
|
||||||
|
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>
|
||||||
614
web/src/lib/components/sidebar/RightSidebar.svelte
Normal file
614
web/src/lib/components/sidebar/RightSidebar.svelte
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
<!--
|
||||||
|
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 { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
Aperture,
|
||||||
|
Calendar,
|
||||||
|
Camera,
|
||||||
|
ExternalLink,
|
||||||
|
Heart,
|
||||||
|
ImageIcon,
|
||||||
|
Lock,
|
||||||
|
MapPin,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
Timer,
|
||||||
|
X
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
buildTakenAtPatch,
|
||||||
|
getAllMarks,
|
||||||
|
likePhoto,
|
||||||
|
renameOnDisk,
|
||||||
|
setMark,
|
||||||
|
unlikePhoto,
|
||||||
|
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 { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photo: PpPhoto;
|
||||||
|
}
|
||||||
|
let { photo }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
let filename = $state('');
|
||||||
|
let caption = $state('');
|
||||||
|
let takenAt = $state('');
|
||||||
|
let lat = $state('');
|
||||||
|
let lng = $state('');
|
||||||
|
let country = $state('');
|
||||||
|
let keywords = $state<string[]>([]);
|
||||||
|
let keywordDraft = $state('');
|
||||||
|
let subject = $state('');
|
||||||
|
let artist = $state('');
|
||||||
|
let copyright = $state('');
|
||||||
|
let license = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
let renaming = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const pf = primaryFile(photo);
|
||||||
|
filename = pf.Name ?? '';
|
||||||
|
caption = photo.Caption ?? '';
|
||||||
|
takenAt = (photo.TakenAt ?? '').slice(0, 16);
|
||||||
|
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);
|
||||||
|
subject = det.Subject ?? '';
|
||||||
|
artist = det.Artist ?? '';
|
||||||
|
copyright = det.Copyright ?? '';
|
||||||
|
license = det.License ?? '';
|
||||||
|
notes = det.Notes ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
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')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const favoriteMutation = createMutation(() => ({
|
||||||
|
mutationFn: async (next: boolean) => {
|
||||||
|
if (next) await likePhoto(photo.UID);
|
||||||
|
else await unlikePhoto(photo.UID);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
onSuccess: (next) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
pushUndo(next ? 'Favorited' : 'Unfavorited', async () => {
|
||||||
|
if (next) await unlikePhoto(photo.UID);
|
||||||
|
else await likePhoto(photo.UID);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
function commit(patch: UpdatePhotoBody) {
|
||||||
|
patchMutation.mutate(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitFilename() {
|
||||||
|
const pf = primaryFile(photo);
|
||||||
|
const next = filename.trim();
|
||||||
|
if (!next || next === pf.Name) 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');
|
||||||
|
filename = pf.Name ?? '';
|
||||||
|
} finally {
|
||||||
|
renaming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitCaption() {
|
||||||
|
if (caption === (photo.Caption ?? '')) return;
|
||||||
|
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||||
|
}
|
||||||
|
function commitTakenAt() {
|
||||||
|
if (!takenAt) return;
|
||||||
|
const iso = `${takenAt}:00Z`;
|
||||||
|
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' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes';
|
||||||
|
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(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrivate() {
|
||||||
|
const prev = photo.Private ?? false;
|
||||||
|
commit({ Private: !prev });
|
||||||
|
pushUndo(prev ? 'Made public' : 'Made private', () => {
|
||||||
|
commit({ Private: prev });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. Same four-swatch palette as mule-image. */
|
||||||
|
function setColor(next: string) {
|
||||||
|
const value = currentColor === next ? '' : next;
|
||||||
|
if (value === currentColor) return;
|
||||||
|
void applyMark({ color: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||||
|
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||||
|
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||||
|
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||||
|
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||||
|
];
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
const mapsHref = $derived(
|
||||||
|
photo.Lat && photo.Lng
|
||||||
|
? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<!-- Header strip — thumb + filename + favorite + private -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={thumbUrl(pf.Hash, 'tile_100')}
|
||||||
|
alt=""
|
||||||
|
class="h-10 w-10 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<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={filename}
|
||||||
|
disabled={renaming}
|
||||||
|
onblur={commitFilename}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
||||||
|
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
||||||
|
class:text-red-500={photo.Favorite}
|
||||||
|
class:text-muted-foreground={!photo.Favorite}
|
||||||
|
disabled={favoriteMutation.isPending}
|
||||||
|
onclick={() => favoriteMutation.mutate(!photo.Favorite)}
|
||||||
|
title={photo.Favorite ? 'Remove favorite (F)' : 'Add favorite (F)'}
|
||||||
|
>
|
||||||
|
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
||||||
|
class:text-foreground={photo.Private}
|
||||||
|
class:text-muted-foreground={!photo.Private}
|
||||||
|
disabled={patchMutation.isPending}
|
||||||
|
onclick={togglePrivate}
|
||||||
|
title={photo.Private ? 'Private' : 'Public'}
|
||||||
|
>
|
||||||
|
<Lock class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact info rows -->
|
||||||
|
<dl class="space-y-1">
|
||||||
|
<!-- Taken at -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
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"
|
||||||
|
bind:value={takenAt}
|
||||||
|
onblur={commitTakenAt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<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 text-muted-foreground">
|
||||||
|
{placeLabel || 'No location'}
|
||||||
|
</span>
|
||||||
|
{#if mapsHref}
|
||||||
|
<a
|
||||||
|
href={mapsHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-muted-foreground hover:text-foreground"
|
||||||
|
title="Open in OpenStreetMap"
|
||||||
|
>
|
||||||
|
<ExternalLink class="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera / lens — only render if something to show -->
|
||||||
|
{#if cameraStr || lensStr || exposureParts.iso || exposureParts.fnum}
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Camera class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div class="min-w-0 flex-1 space-y-0.5 text-muted-foreground">
|
||||||
|
{#if cameraStr}<div class="truncate">{cameraStr}</div>{/if}
|
||||||
|
{#if lensStr && lensStr !== cameraStr}<div class="truncate">{lensStr}</div>{/if}
|
||||||
|
{#if exposureParts.iso || exposureParts.fnum || exposureParts.focal || exposureParts.exp}
|
||||||
|
<div class="flex flex-wrap gap-x-2 text-[10px]">
|
||||||
|
{#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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
|
||||||
|
mule-image's nomenclature). -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Score + color label — two separate sections. Click a star/swatch to
|
||||||
|
set, click the active one to clear. Sits next to Keywords because
|
||||||
|
these are the per-photo culling marks the user reaches for in the
|
||||||
|
same workflow. Stored on the mule-sidecar; PhotoPrism's PUT can't
|
||||||
|
persist them. -->
|
||||||
|
<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">Color label</div>
|
||||||
|
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||||
|
{#each COLOR_SWATCHES as c (c.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
|
||||||
|
class:ring-foreground={currentColor === c.key}
|
||||||
|
class:ring-transparent={currentColor !== c.key}
|
||||||
|
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="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Tag class="h-3 w-3" /> 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>
|
||||||
|
|
||||||
|
<!-- GPS detail (collapsed by default) -->
|
||||||
|
<details class="rounded border border-border" open={Boolean(photo.Lat || photo.Lng)}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- IPTC credits (collapsed unless something set) -->
|
||||||
|
<details
|
||||||
|
class="rounded border border-border"
|
||||||
|
open={Boolean(subject || artist || copyright || license || notes)}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Credits & notes
|
||||||
|
</summary>
|
||||||
|
<div class="space-y-1 p-2 pt-1">
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<span class="w-16 text-[10px] text-muted-foreground">Subject</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={subject}
|
||||||
|
onblur={() => commitDetails('Subject', subject)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<span class="w-16 text-[10px] text-muted-foreground">Artist</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={artist}
|
||||||
|
onblur={() => commitDetails('Artist', artist)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<span class="w-16 text-[10px] text-muted-foreground">Copyright</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={copyright}
|
||||||
|
onblur={() => commitDetails('Copyright', copyright)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<span class="w-16 text-[10px] text-muted-foreground">License</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={license}
|
||||||
|
onblur={() => commitDetails('License', license)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-1">
|
||||||
|
<span class="w-16 pt-0.5 text-[10px] text-muted-foreground">Private notes</span>
|
||||||
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
class="min-w-0 flex-1 resize-y rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={notes}
|
||||||
|
onblur={() => commitDetails('Notes', notes)}
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- File (collapsed by default) -->
|
||||||
|
<details class="rounded border border-border">
|
||||||
|
<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]">
|
||||||
|
<dt class="text-muted-foreground">Size</dt>
|
||||||
|
<dd class="text-foreground/80">{dims} · {sizeStr}</dd>
|
||||||
|
<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>
|
||||||
289
web/src/lib/components/timeline/BulkActionBar.svelte
Normal file
289
web/src/lib/components/timeline/BulkActionBar.svelte
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
addToHeap,
|
||||||
|
batchArchive,
|
||||||
|
batchDelete,
|
||||||
|
batchRestore,
|
||||||
|
likePhoto,
|
||||||
|
listHeaps,
|
||||||
|
removeFromHeap,
|
||||||
|
unlikePhoto,
|
||||||
|
type PpAlbum
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { batchEdit } from '$lib/services/batch';
|
||||||
|
import { clearSelection, selection, setFocused } from '$lib/stores/selection.svelte';
|
||||||
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
|
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
let busy = $state(false);
|
||||||
|
let heapPickerOpen = $state(false);
|
||||||
|
|
||||||
|
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||||||
|
queryKey: ['heaps'],
|
||||||
|
queryFn: listHeaps,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targets of an action: the multi-selected set when one exists, else the
|
||||||
|
* focused tile alone. Mule-image's design treats focus as "implicit single
|
||||||
|
* selection" so the bar's actions always have something to operate on.
|
||||||
|
*/
|
||||||
|
function snapshotIds(): string[] {
|
||||||
|
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||||
|
if (selection.focused) return [selection.focused];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCount = $derived(
|
||||||
|
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||||
|
);
|
||||||
|
const isBulk = $derived(selection.ids.size > 0);
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
clearSelection();
|
||||||
|
setFocused(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onArchive() {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await withBusy(async () => {
|
||||||
|
try {
|
||||||
|
await batchArchive(ids);
|
||||||
|
pushUndo(`Archived ${ids.length}`, async () => {
|
||||||
|
await batchRestore(ids);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
toast.success(`Archived ${ids.length}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (ids.length === 0) 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;
|
||||||
|
await withBusy(async () => {
|
||||||
|
try {
|
||||||
|
await batchDelete(ids);
|
||||||
|
clearSelection();
|
||||||
|
toast.success(`Deleted ${ids.length}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRestore() {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await withBusy(async () => {
|
||||||
|
try {
|
||||||
|
await batchRestore(ids);
|
||||||
|
pushUndo(`Restored ${ids.length}`, async () => {
|
||||||
|
await batchArchive(ids);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
toast.success(`Restored ${ids.length}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFavorite() {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await withBusy(async () => {
|
||||||
|
const { updated, errors } = await batchEdit(ids, (id) => likePhoto(id));
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`Favorited ${updated.length}; ${errors.length} failed`);
|
||||||
|
} else {
|
||||||
|
toast.success(`Favorited ${ids.length}`);
|
||||||
|
}
|
||||||
|
pushUndo(`Favorited ${ids.length}`, async () => {
|
||||||
|
await batchEdit(ids, (id) => unlikePhoto(id));
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUndo() {
|
||||||
|
const entry = await popAndRun();
|
||||||
|
if (entry) toast.success(`Undone: ${entry.label}`);
|
||||||
|
else toast.message('Nothing to undo');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddToHeap(heap: PpAlbum) {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
heapPickerOpen = false;
|
||||||
|
await withBusy(async () => {
|
||||||
|
try {
|
||||||
|
await addToHeap(heap.UID, ids);
|
||||||
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
toast.success(`Added ${ids.length} → ${heap.Title}`);
|
||||||
|
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
|
||||||
|
await removeFromHeap(heap.UID, ids);
|
||||||
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if targetCount > 0}
|
||||||
|
<div
|
||||||
|
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-foreground">
|
||||||
|
{#if isBulk}
|
||||||
|
{targetCount} selected
|
||||||
|
{:else}
|
||||||
|
Focused photo
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="inline-flex 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}
|
||||||
|
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||||
|
title="Add to heap (S then 1–9 picks a heap)"
|
||||||
|
>
|
||||||
|
+ Add to heap
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>S N</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{#if heapPickerOpen}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
|
||||||
|
>
|
||||||
|
{#if heapsQuery.isPending}
|
||||||
|
<p class="px-2 py-1 text-muted-foreground">Loading…</p>
|
||||||
|
{:else if (heapsQuery.data ?? []).length === 0}
|
||||||
|
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p>
|
||||||
|
{:else}
|
||||||
|
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-accent"
|
||||||
|
onclick={() => onAddToHeap(heap)}
|
||||||
|
>
|
||||||
|
{#if i < 9}
|
||||||
|
<kbd
|
||||||
|
class="shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
title={`S ${i + 1}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</kbd>
|
||||||
|
{:else}
|
||||||
|
<span class="w-3 shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="flex-1 truncate">{heap.Title}</span>
|
||||||
|
<span class="shrink-0 text-muted-foreground">
|
||||||
|
({heap.PhotoCount ?? 0})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex 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}
|
||||||
|
onclick={onFavorite}
|
||||||
|
title="Favorite"
|
||||||
|
>
|
||||||
|
♥ Favorite
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>F</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex 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}
|
||||||
|
onclick={onArchive}
|
||||||
|
title="Archive"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>X</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex 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}
|
||||||
|
onclick={onRestore}
|
||||||
|
title="Restore"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>U</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{#if filters.section === 'archive'}
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={onDelete}
|
||||||
|
title="Permanently delete (no undo)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="inline-flex 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 || undoStack.entries.length === 0}
|
||||||
|
onclick={onUndo}
|
||||||
|
title="Undo last action"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>⌘Z</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
|
||||||
|
onclick={clearAll}
|
||||||
|
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||||
|
>
|
||||||
|
{isBulk ? 'Clear' : 'Dismiss'}
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>Esc</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
1
web/src/lib/index.ts
Normal file
1
web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
15
web/src/lib/queryClient.ts
Normal file
15
web/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { QueryClient } from '@tanstack/svelte-query';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide QueryClient singleton. `+layout.svelte` wires it into the
|
||||||
|
* provider; non-component code (Svelte actions, keyboard handlers) imports
|
||||||
|
* it directly to read cached photo state and invalidate after mutations.
|
||||||
|
*/
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
41
web/src/lib/services/adapters/duplicates.ts
Normal file
41
web/src/lib/services/adapters/duplicates.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Adapter that shapes PhotoPrism's `q=stack:true` photo list into a
|
||||||
|
* `DuplicateGroup[]` the UI consumes. Mirrors mule-image's
|
||||||
|
* `DuplicateGroup` interface so the view code stays declarative.
|
||||||
|
*
|
||||||
|
* PhotoPrism's `/photos?merged=true` inlines `Files[]` on each row
|
||||||
|
* already — no follow-up GET per group needed (confirmed against
|
||||||
|
* photoprism/photoprism:latest, May 2026).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { listPhotos } from '$lib/services/photoprism';
|
||||||
|
import type { PpFile, PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
/** The Photo record that owns this stack. Carries marks, keywords,
|
||||||
|
* taken_at — everything the metadata sidebar reads off of. */
|
||||||
|
photo: PpPhoto;
|
||||||
|
/** The variants the user picks among. Always 2+ entries; groups with
|
||||||
|
* a single file are filtered out (PhotoPrism shouldn't return them
|
||||||
|
* for `stack:true` anyway, but the guard is cheap). */
|
||||||
|
files: PpFile[];
|
||||||
|
/** Pre-selected "keep this" file = the one PhotoPrism marks Primary.
|
||||||
|
* The view starts with this highlighted and updates it on click. */
|
||||||
|
bestFileUid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
|
||||||
|
const photos = await listPhotos({
|
||||||
|
q: 'stack:true',
|
||||||
|
count: 200,
|
||||||
|
merged: true,
|
||||||
|
order: 'newest'
|
||||||
|
});
|
||||||
|
return photos
|
||||||
|
.filter((p) => (p.Files?.length ?? 0) > 1)
|
||||||
|
.map((p) => {
|
||||||
|
const files = p.Files ?? [];
|
||||||
|
const primary = files.find((f) => f.Primary) ?? files[0];
|
||||||
|
return { photo: p, files, bestFileUid: primary.UID };
|
||||||
|
});
|
||||||
|
}
|
||||||
50
web/src/lib/services/batch.ts
Normal file
50
web/src/lib/services/batch.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Fan-out helper used wherever PhotoPrism lacks a true batch endpoint.
|
||||||
|
* Bounded concurrency keeps the indexer happy on slow hosts; each item's
|
||||||
|
* result is collected and the aggregate `{updated, errors[]}` mirrors the
|
||||||
|
* shape the legacy mule-image bulk endpoint returned, so existing toast
|
||||||
|
* + undo plumbing slots in without changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BatchResult<T> {
|
||||||
|
updated: T[];
|
||||||
|
errors: { id: string; message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptions {
|
||||||
|
concurrency?: number;
|
||||||
|
onProgress?: (done: number, total: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchEdit<T>(
|
||||||
|
ids: string[],
|
||||||
|
fn: (id: string) => Promise<T>,
|
||||||
|
opts: BatchOptions = {}
|
||||||
|
): Promise<BatchResult<T>> {
|
||||||
|
const concurrency = Math.max(1, opts.concurrency ?? 8);
|
||||||
|
const updated: T[] = [];
|
||||||
|
const errors: { id: string; message: string }[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const idx = i++;
|
||||||
|
if (idx >= ids.length) return;
|
||||||
|
const id = ids[idx];
|
||||||
|
try {
|
||||||
|
updated.push(await fn(id));
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
|
||||||
|
} finally {
|
||||||
|
done++;
|
||||||
|
opts.onProgress?.(done, ids.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
|
||||||
|
);
|
||||||
|
return { updated, errors };
|
||||||
|
}
|
||||||
87
web/src/lib/services/bulk.ts
Normal file
87
web/src/lib/services/bulk.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { batchEdit } from './batch';
|
||||||
|
import { getPhoto, updatePhoto, type UpdatePhotoBody } from './photoprism';
|
||||||
|
import { queryClient } from '$lib/queryClient';
|
||||||
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared helpers for bulk metadata mutations across the timeline. Three call
|
||||||
|
* sites: the keyboard culling layer (`gridKeyNav`), the `BulkActionBar` row,
|
||||||
|
* and the bulk metadata sidebar shown on multi-select. Each needs the same
|
||||||
|
* "fetch full photo body → deep-merge patch → PUT → invalidate" round-trip
|
||||||
|
* (PhotoPrism's PUT only persists nested fields when the body is whole), so
|
||||||
|
* the wiring lives here to keep the call sites declarative.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fetch the freshest photo body, seeding the per-photo cache. PhotoPrism's
|
||||||
|
* PUT needs the full body to persist `Rating` / `Color` / `Details.*`; the
|
||||||
|
* cache lookup means the subsequent patch pass reuses this fetch. */
|
||||||
|
export async function freshPhoto(uid: string): Promise<PpPhoto> {
|
||||||
|
const cached = queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
if (cached) return cached;
|
||||||
|
const p = await getPhoto(uid);
|
||||||
|
queryClient.setQueryData(['photo', uid], p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidatePhotos(uids: string[]): void {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
for (const id of uids) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a patch to every uid. The patch can be a static body or a per-photo
|
||||||
|
* function (used by keyword merges which need to read each photo's current
|
||||||
|
* Details.Keywords before extending it). When `inverseBuilder` is provided,
|
||||||
|
* an undo entry is registered that restores each photo's pre-patch state.
|
||||||
|
*/
|
||||||
|
export async function patchTargets(
|
||||||
|
ids: string[],
|
||||||
|
patch: UpdatePhotoBody | ((p: PpPhoto) => UpdatePhotoBody),
|
||||||
|
label: string,
|
||||||
|
inverseBuilder?: (photo: PpPhoto) => UpdatePhotoBody
|
||||||
|
): Promise<void> {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const inverses = inverseBuilder
|
||||||
|
? new Map<string, UpdatePhotoBody>(
|
||||||
|
await Promise.all(
|
||||||
|
ids.map(async (id) => {
|
||||||
|
const p = await freshPhoto(id);
|
||||||
|
return [id, inverseBuilder(p)] as const;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { updated, errors } = await batchEdit(ids, async (id) => {
|
||||||
|
const p = await freshPhoto(id);
|
||||||
|
const body = typeof patch === 'function' ? patch(p) : patch;
|
||||||
|
// An empty body is a no-op signal — e.g. "keyword already present".
|
||||||
|
if (Object.keys(body).length === 0) return p;
|
||||||
|
return updatePhoto(p, body);
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
|
||||||
|
} else {
|
||||||
|
toast.success(`${label} · ${ids.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inverses) {
|
||||||
|
pushUndo(`${label} (${ids.length})`, async () => {
|
||||||
|
await batchEdit(ids, async (id) => {
|
||||||
|
const p = await freshPhoto(id);
|
||||||
|
const inv = inverses.get(id) ?? {};
|
||||||
|
if (Object.keys(inv).length === 0) return p;
|
||||||
|
return updatePhoto(p, inv);
|
||||||
|
});
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
607
web/src/lib/services/photoprism.ts
Normal file
607
web/src/lib/services/photoprism.ts
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
|
||||||
|
import type {
|
||||||
|
PpClientConfig,
|
||||||
|
PpPhoto,
|
||||||
|
PpSessionResponse,
|
||||||
|
PpUser
|
||||||
|
} from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios client pre-configured for PhotoPrism's /api/v1. Same-origin in dev
|
||||||
|
* (vite proxies /api → photoprism:2342), same-origin in prod (Caddy fronts
|
||||||
|
* both the SPA and PhotoPrism on one hostname).
|
||||||
|
*/
|
||||||
|
const http: AxiosInstance = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.request.use((config) => {
|
||||||
|
if (session.accessToken) {
|
||||||
|
config.headers = config.headers ?? {};
|
||||||
|
(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
(err: AxiosError) => {
|
||||||
|
if (err.response?.status === 401 && browser) {
|
||||||
|
clearSession();
|
||||||
|
// Avoid redirect loops if the request was a login probe.
|
||||||
|
const url = err.config?.url ?? '';
|
||||||
|
if (!url.endsWith('/session')) {
|
||||||
|
void goto('/login', { replaceState: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<PpSessionResponse> {
|
||||||
|
const { data } = await http.post<PpSessionResponse>('/session', { username, password });
|
||||||
|
adoptSession(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
if (session.id) {
|
||||||
|
try {
|
||||||
|
await http.delete(`/session/${session.id}`);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: even if PhotoPrism rejects, drop the client state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSession(id: string): Promise<PpSessionResponse> {
|
||||||
|
const { data } = await http.get<PpSessionResponse>(`/session/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<PpClientConfig> {
|
||||||
|
const { data } = await http.get<PpClientConfig>('/config');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photos ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ListPhotosParams {
|
||||||
|
q?: string;
|
||||||
|
count?: number;
|
||||||
|
offset?: number;
|
||||||
|
order?: 'newest' | 'oldest' | 'added' | 'edited' | 'name';
|
||||||
|
merged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
|
||||||
|
const { data } = await http.get<PpPhoto[]>('/photos', {
|
||||||
|
params: {
|
||||||
|
count: 60,
|
||||||
|
offset: 0,
|
||||||
|
order: 'newest',
|
||||||
|
merged: true,
|
||||||
|
...params
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPhoto(uid: string): Promise<PpPhoto> {
|
||||||
|
const { data } = await http.get<PpPhoto>(`/photos/${uid}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch payload accepted by `updatePhoto`. Top-level scalars merge in
|
||||||
|
* place; `Details` is shallow-merged onto the existing Details object so
|
||||||
|
* callers can patch a single Details field (Keywords, Subject, …) without
|
||||||
|
* clobbering siblings.
|
||||||
|
*
|
||||||
|
* **PhotoPrism quirk**: nested `Details.*` only persists when the PUT
|
||||||
|
* carries the FULL photo body — partial PUTs silently no-op for those
|
||||||
|
* fields. `updatePhoto` handles the fetch/merge/PUT round-trip so callers
|
||||||
|
* can stick to a partial shape.
|
||||||
|
*/
|
||||||
|
export interface UpdatePhotoBody {
|
||||||
|
OriginalName?: string;
|
||||||
|
Caption?: string;
|
||||||
|
CaptionSrc?: 'manual' | '';
|
||||||
|
Favorite?: boolean;
|
||||||
|
Private?: boolean;
|
||||||
|
Archived?: boolean;
|
||||||
|
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
|
||||||
|
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */
|
||||||
|
TakenAt?: string;
|
||||||
|
TakenAtLocal?: string;
|
||||||
|
TakenSrc?: 'manual' | '';
|
||||||
|
Year?: number;
|
||||||
|
Month?: number;
|
||||||
|
Day?: number;
|
||||||
|
TimeZone?: string;
|
||||||
|
Lat?: number;
|
||||||
|
Lng?: number;
|
||||||
|
Altitude?: number;
|
||||||
|
Country?: string;
|
||||||
|
CountrySrc?: 'manual' | '';
|
||||||
|
Details?: Partial<import('$lib/types/photoprism').PpDetails>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return {};
|
||||||
|
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
||||||
|
return {
|
||||||
|
TakenAt: utc,
|
||||||
|
TakenAtLocal: utc,
|
||||||
|
TakenSrc: 'manual',
|
||||||
|
Year: d.getUTCFullYear(),
|
||||||
|
Month: d.getUTCMonth() + 1,
|
||||||
|
Day: d.getUTCDate()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a partial `UpdatePhotoBody` onto a full photo and PUT the result.
|
||||||
|
* Required for `Details.*` because PhotoPrism rejects partial bodies for
|
||||||
|
* nested fields. Top-level fields would work with a thinner body but we
|
||||||
|
* unify on full-body PUT to keep the call sites simple.
|
||||||
|
*/
|
||||||
|
export async function updatePhoto(photo: PpPhoto, patch: UpdatePhotoBody): Promise<PpPhoto> {
|
||||||
|
const merged: Record<string, unknown> = { ...photo, ...patch };
|
||||||
|
if (patch.Details) {
|
||||||
|
merged.Details = { ...(photo.Details ?? {}), ...patch.Details };
|
||||||
|
}
|
||||||
|
const { data } = await http.put<PpPhoto>(`/photos/${photo.UID}`, merged);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BatchPhotosBody {
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBatchBody(uids: string[]): BatchPhotosBody {
|
||||||
|
// PhotoPrism's batch endpoints expect a flat array of UIDs, not
|
||||||
|
// `{UID: ...}` objects (verified against the bundled Vue client).
|
||||||
|
return { photos: uids };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchArchive(uids: string[]): Promise<void> {
|
||||||
|
await http.post('/batch/photos/archive', toBatchBody(uids));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchRestore(uids: string[]): Promise<void> {
|
||||||
|
await http.post('/batch/photos/restore', toBatchBody(uids));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete photos. PhotoPrism only accepts UIDs that are already
|
||||||
|
* archived — calling on a live photo returns 4xx. Irreversible; no undo
|
||||||
|
* counterpart.
|
||||||
|
*/
|
||||||
|
export async function batchDelete(uids: string[]): Promise<void> {
|
||||||
|
await http.post('/batch/photos/delete', toBatchBody(uids));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
||||||
|
* routes that are atomic; preferred over PUT for this one field.
|
||||||
|
*/
|
||||||
|
export async function likePhoto(uid: string): Promise<void> {
|
||||||
|
await http.post(`/photos/${uid}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlikePhoto(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/photos/${uid}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stack file operations ───────────────────────────────────────────────────
|
||||||
|
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
|
||||||
|
// a single Photo entity. The duplicate-resolution flow needs two ops, both
|
||||||
|
// nested under the photo UID:
|
||||||
|
// - setPrimary: pick which file is the canonical/cover for the stack.
|
||||||
|
// - unstackFile: pull a file out of the stack so it becomes its own Photo
|
||||||
|
// record (which can then be archived via batchArchive). PhotoPrism returns
|
||||||
|
// the freshly-promoted parent photo body on success.
|
||||||
|
//
|
||||||
|
// PhotoPrism refuses to unstack auto-generated sidecar files (e.g. `.jpg`
|
||||||
|
// companions next to a RAW) and live-photo pairs — both return 4xx/5xx. The
|
||||||
|
// callers above must surface the failure rather than retry, hence the
|
||||||
|
// passthrough error from the axios layer.
|
||||||
|
|
||||||
|
export async function setPrimary(photoUid: string, fileUid: string): Promise<void> {
|
||||||
|
await http.post(`/photos/${photoUid}/files/${fileUid}/primary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unstackFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
|
||||||
|
const { data } = await http.post<PpPhoto>(`/photos/${photoUid}/files/${fileUid}/unstack`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a non-primary file from a stack. PhotoPrism cascades the delete
|
||||||
|
* through related variants in the same logical group (e.g. deleting one
|
||||||
|
* file of a Live Photo HEIC+MOV pair removes the whole pair). The file is
|
||||||
|
* NOT erased from disk; PhotoPrism renames the on-disk file with a hash
|
||||||
|
* suffix to take it out of the indexer's path. The response is the
|
||||||
|
* updated parent photo body.
|
||||||
|
*
|
||||||
|
* Used by the duplicate-resolution flow as the practical "discard rest"
|
||||||
|
* primitive because `/unstack` returns 5xx for live-photo and sidecar
|
||||||
|
* files (`only originals can be unstacked` / `Changes could not be saved`).
|
||||||
|
*/
|
||||||
|
export async function deleteFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
|
||||||
|
const { data } = await http.delete<PpPhoto>(`/photos/${photoUid}/files/${fileUid}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folders ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PpFolder {
|
||||||
|
UID: string;
|
||||||
|
Path: string;
|
||||||
|
Root: string;
|
||||||
|
Title: string;
|
||||||
|
FileCount?: number;
|
||||||
|
Favorite?: boolean;
|
||||||
|
Private?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursive list of subfolders under originals/. `uncached=true` because
|
||||||
|
* PhotoPrism's folder cache lags new folders by a noticeable interval and
|
||||||
|
* mule-image's folder tree expects to surface mutations immediately.
|
||||||
|
*/
|
||||||
|
export async function listFolders(): Promise<PpFolder[]> {
|
||||||
|
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
||||||
|
'/folders/originals',
|
||||||
|
{ params: { recursive: true, uncached: true, files: false } }
|
||||||
|
);
|
||||||
|
return data.folders ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PpGeoFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
id: string;
|
||||||
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
properties: {
|
||||||
|
UID: string;
|
||||||
|
Hash: string;
|
||||||
|
Title?: string;
|
||||||
|
TakenAt?: string;
|
||||||
|
FavId?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpGeoCollection {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: PpGeoFeature[];
|
||||||
|
bbox?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGeo(q = ''): Promise<PpGeoCollection> {
|
||||||
|
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
|
||||||
|
// matching geocoded photo. MapLibre's native clustering handles 50k+
|
||||||
|
// points without breaking a sweat (PhotoPrism upstream documents
|
||||||
|
// 500k); we ask for a generous cap that covers realistic libraries.
|
||||||
|
const { data } = await http.get<PpGeoCollection>('/geo', {
|
||||||
|
params: { count: 50000, q: q || undefined }
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Labels ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PpLabel {
|
||||||
|
UID: string;
|
||||||
|
Slug: string;
|
||||||
|
CustomSlug?: string;
|
||||||
|
Name: string;
|
||||||
|
Favorite?: boolean;
|
||||||
|
Priority?: number;
|
||||||
|
Description?: string;
|
||||||
|
PhotoCount?: number;
|
||||||
|
Thumb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLabels(): Promise<PpLabel[]> {
|
||||||
|
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||||
|
// low-confidence classifier hits, manually-removed labels). They're
|
||||||
|
// still attached to photos in the DB and the `label:<slug>` query
|
||||||
|
// still resolves; without `all=true` the /labels endpoint filters
|
||||||
|
// them out and the tags page silently shows only ~40% of the user's
|
||||||
|
// real tag set. `count` bumped to 1000 so a moderately tagged library
|
||||||
|
// returns the full list in one round-trip.
|
||||||
|
const { data } = await http.get<PpLabel[]>('/labels', {
|
||||||
|
params: { count: 1000, order: 'count', all: true }
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Albums = Heaps ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PpAlbum {
|
||||||
|
UID: string;
|
||||||
|
Slug?: string;
|
||||||
|
Type: string;
|
||||||
|
Title: string;
|
||||||
|
Description?: string;
|
||||||
|
Favorite?: boolean;
|
||||||
|
PhotoCount?: number;
|
||||||
|
CreatedAt?: string;
|
||||||
|
UpdatedAt?: string;
|
||||||
|
Thumb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHeaps(): Promise<PpAlbum[]> {
|
||||||
|
const { data } = await http.get<PpAlbum[]>('/albums', {
|
||||||
|
params: { type: 'album', count: 500, order: 'newest' }
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHeap(uid: string): Promise<PpAlbum> {
|
||||||
|
const { data } = await http.get<PpAlbum>(`/albums/${uid}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHeap(title: string): Promise<PpAlbum> {
|
||||||
|
const { data } = await http.post<PpAlbum>('/albums', {
|
||||||
|
Title: title,
|
||||||
|
Type: 'album'
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameHeap(uid: string, title: string): Promise<PpAlbum> {
|
||||||
|
const { data } = await http.put<PpAlbum>(`/albums/${uid}`, { Title: title });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHeap(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/albums/${uid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToHeap(uid: string, photos: string[]): Promise<void> {
|
||||||
|
await http.post(`/albums/${uid}/photos`, { photos });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {
|
||||||
|
await http.delete(`/albums/${uid}/photos`, { data: { photos } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a heap. PhotoPrism has no native duplicate endpoint, so we fan out
|
||||||
|
* three round-trips: read the source title, list its members via the q-DSL
|
||||||
|
* (the same `album:<UID>` filter the timeline uses for the heap view), create
|
||||||
|
* a new "X (copy)" album, then add every member to it. Matches mule-image's
|
||||||
|
* backend `POST /heaps/{id}/duplicate` behaviour.
|
||||||
|
*/
|
||||||
|
export async function duplicateHeap(uid: string): Promise<PpAlbum> {
|
||||||
|
const source = await getHeap(uid);
|
||||||
|
const members = await listPhotos({ q: `album:${uid}`, count: 1000 });
|
||||||
|
const copy = await createHeap(`${source.Title} (copy)`);
|
||||||
|
if (members.length > 0) {
|
||||||
|
await addToHeap(
|
||||||
|
copy.UID,
|
||||||
|
members.map((p) => p.UID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL for PhotoPrism's album-as-zip download. The download token comes from
|
||||||
|
* the session config and is what authenticates the GET — no auth header
|
||||||
|
* needed, which is why the URL can be opened in a fresh window/tab.
|
||||||
|
*/
|
||||||
|
export function heapDownloadUrl(uid: string): string {
|
||||||
|
const t = session.downloadToken ?? '';
|
||||||
|
return `/api/v1/albums/${uid}/dl?t=${encodeURIComponent(t)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a browser download by injecting a transient <a> element and
|
||||||
|
* clicking it. Matches the pattern from mule-image's `downloads.trigger`.
|
||||||
|
* Uses target=_blank so PhotoPrism's zip response (which streams) doesn't
|
||||||
|
* navigate the current page away.
|
||||||
|
*/
|
||||||
|
export function triggerDownload(url: string): void {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.target = '_blank';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mule-sidecar (Node prototype today, Go in M4) ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the primary file of a photo on disk. PhotoPrism's `OriginalName`
|
||||||
|
* field only updates the display name; this calls the mule-sidecar service
|
||||||
|
* to issue an actual `os.Rename` under `originals/` and then trigger a
|
||||||
|
* PhotoPrism reindex of the parent path.
|
||||||
|
*
|
||||||
|
* Network: same-origin via the dev proxy entry `/api/sidecar/*`.
|
||||||
|
*/
|
||||||
|
export interface RenameResult {
|
||||||
|
ok: boolean;
|
||||||
|
oldName: string;
|
||||||
|
newName: string;
|
||||||
|
oldRelPath: string;
|
||||||
|
newRelPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
|
||||||
|
const res = await fetch(`/api/sidecar${urlPath}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Auth-Token': session.accessToken ?? ''
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (data as { error?: string }).error ?? `HTTP ${res.status}`;
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(relPath: string): Promise<{ path: string }> {
|
||||||
|
return sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(
|
||||||
|
relPath: string,
|
||||||
|
newName: string
|
||||||
|
): Promise<{ oldPath: string; newPath: string }> {
|
||||||
|
return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
|
||||||
|
newName
|
||||||
|
}) as Promise<{ oldPath: string; newPath: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolder(relPath: string): Promise<{ path: string }> {
|
||||||
|
return sidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
|
||||||
|
path: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-folder duplicate detection (sidecar-driven) ───────────────────────
|
||||||
|
// PhotoPrism silently drops byte-identical files at index time, so duplicates
|
||||||
|
// across folders never enter its DB. The sidecar walks the originals tree,
|
||||||
|
// pre-filters by size, sha1s the survivors, and returns the hash-collision
|
||||||
|
// groups. Resolution moves the unwanted copies into a `.duplicates/`
|
||||||
|
// quarantine folder PhotoPrism's indexer ignores.
|
||||||
|
|
||||||
|
export interface DupFileEntry {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrossFolderDuplicateGroup {
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
/** Path of the file PhotoPrism currently has indexed for this hash,
|
||||||
|
* or null if none (the rare case of every copy being dropped). The
|
||||||
|
* UI uses this to default the "keep" pick. */
|
||||||
|
indexedPath: string | null;
|
||||||
|
files: DupFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrossFolderScanResult {
|
||||||
|
groups: CrossFolderDuplicateGroup[];
|
||||||
|
scannedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanCrossFolderDuplicates(): Promise<CrossFolderScanResult> {
|
||||||
|
return sidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveDuplicatesResult {
|
||||||
|
moved: { from: string; to: string }[];
|
||||||
|
errors: { path: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveDuplicatePaths(
|
||||||
|
paths: string[]
|
||||||
|
): Promise<ArchiveDuplicatesResult> {
|
||||||
|
return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
||||||
|
// Lives on the sidecar because moving the underlying files is a filesystem
|
||||||
|
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
|
||||||
|
// via PhotoPrism's q-DSL, fs.rename / fs.copyFile each primary file into the
|
||||||
|
// target folder, then triggers a PhotoPrism reindex.
|
||||||
|
|
||||||
|
export interface HeapConvertBody {
|
||||||
|
/** Originals-relative target folder. Must exist. */
|
||||||
|
targetFolder: string;
|
||||||
|
mode: 'move' | 'copy';
|
||||||
|
/** Optional subfolder name to create under `targetFolder` and place
|
||||||
|
* files into. Lets the user keep a heap's worth of files grouped. */
|
||||||
|
subfolder?: string | null;
|
||||||
|
/** Delete the album after a successful move. Ignored when mode='copy'
|
||||||
|
* (a copy doesn't change membership). */
|
||||||
|
deleteHeap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeapConvertResult {
|
||||||
|
moved: number;
|
||||||
|
copied: number;
|
||||||
|
errors: { uid: string; reason: string }[];
|
||||||
|
heap_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertHeap(
|
||||||
|
uid: string,
|
||||||
|
body: HeapConvertBody
|
||||||
|
): Promise<HeapConvertResult> {
|
||||||
|
return sidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||||
|
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
||||||
|
// internal fields). We store them in mule-sidecar instead.
|
||||||
|
|
||||||
|
export interface PhotoMark {
|
||||||
|
rating?: number;
|
||||||
|
color?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PhotoMarksMap = Record<string, PhotoMark>;
|
||||||
|
|
||||||
|
export async function getAllMarks(): Promise<PhotoMarksMap> {
|
||||||
|
const data = await sidecar('GET', '/photos/marks');
|
||||||
|
return (data ?? {}) as PhotoMarksMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> {
|
||||||
|
return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkSetMarks(
|
||||||
|
ids: string[],
|
||||||
|
patch: PhotoMark
|
||||||
|
): Promise<{ count: number; marks: PhotoMarksMap }> {
|
||||||
|
return sidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
|
||||||
|
count: number;
|
||||||
|
marks: PhotoMarksMap;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameOnDisk(photoUid: string, newName: string): Promise<RenameResult> {
|
||||||
|
// Bypass the axios client because the sidecar lives at /api/sidecar, not
|
||||||
|
// /api/v1 — http.baseURL would prepend the wrong prefix.
|
||||||
|
const res = await fetch(`/api/sidecar/files/${photoUid}/rename`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Auth-Token': session.accessToken ?? ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ newName })
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as Partial<RenameResult> & { error?: string };
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `Rename failed (${res.status})`);
|
||||||
|
return data as RenameResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Re-exports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
|
||||||
107
web/src/lib/stores/filters.svelte.ts
Normal file
107
web/src/lib/stores/filters.svelte.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Filter state for the timeline. One source of truth; routes read from it,
|
||||||
|
* the left sidebar writes to it, and `filtersToQ()` derives the search
|
||||||
|
* string PhotoPrism's `q` parameter accepts.
|
||||||
|
*
|
||||||
|
* Sections behave like saved searches: picking a section sets a default
|
||||||
|
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
|
||||||
|
* etc.), and the search box on top stacks an additional `q` term.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Section =
|
||||||
|
| 'all-photos'
|
||||||
|
| 'favorites'
|
||||||
|
| 'archive'
|
||||||
|
| 'heap';
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
section: Section;
|
||||||
|
/** Heap UID, used when section === 'heap'. */
|
||||||
|
heapUid: string | null;
|
||||||
|
/** Relative folder path under originals/. Stacks with section terms. */
|
||||||
|
folderPath: string | null;
|
||||||
|
/** Free-form search text, ANDed with section-derived terms. */
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filters = $state<FilterState>({
|
||||||
|
section: 'all-photos',
|
||||||
|
heapUid: null,
|
||||||
|
folderPath: null,
|
||||||
|
search: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setSection(section: Section, heapUid: string | null = null): void {
|
||||||
|
filters.section = section;
|
||||||
|
filters.heapUid = section === 'heap' ? heapUid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSearch(q: string): void {
|
||||||
|
filters.search = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFolderPath(path: string | null): void {
|
||||||
|
filters.folderPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote a DSL term value when it contains characters that PhotoPrism's
|
||||||
|
* parser treats as boundaries (spaces, colons). We surround in double
|
||||||
|
* quotes; users can still type a raw `q=` for advanced search.
|
||||||
|
*/
|
||||||
|
function quoteIfNeeded(v: string): string {
|
||||||
|
if (!v) return '';
|
||||||
|
if (/^[A-Za-z0-9_\-./]+$/.test(v)) return v;
|
||||||
|
return `"${v.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the PhotoPrism `q=` DSL string from the current filter state.
|
||||||
|
* Returns "" when nothing's restricting (the timeline default).
|
||||||
|
*/
|
||||||
|
export function filtersToQ(f: FilterState = filters): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
switch (f.section) {
|
||||||
|
case 'favorites':
|
||||||
|
parts.push('favorite:true');
|
||||||
|
break;
|
||||||
|
case 'archive':
|
||||||
|
parts.push('archived:true');
|
||||||
|
break;
|
||||||
|
case 'heap':
|
||||||
|
if (f.heapUid) parts.push(`album:${f.heapUid}`);
|
||||||
|
break;
|
||||||
|
case 'all-photos':
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
|
||||||
|
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of filtersToQ for URL hydration. Returns the parsed filter state. */
|
||||||
|
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||||
|
const sectionRaw = params.get('section') as Section | null;
|
||||||
|
const section: Section =
|
||||||
|
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
|
||||||
|
? sectionRaw
|
||||||
|
: 'all-photos';
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
heapUid: params.get('heap'),
|
||||||
|
folderPath: params.get('folder'),
|
||||||
|
search: params.get('q') ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialise the current filter state to URL search params (only set keys
|
||||||
|
* that differ from defaults so the URL stays clean). */
|
||||||
|
export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (f.section !== 'all-photos') params.set('section', f.section);
|
||||||
|
if (f.heapUid) params.set('heap', f.heapUid);
|
||||||
|
if (f.folderPath) params.set('folder', f.folderPath);
|
||||||
|
if (f.search) params.set('q', f.search);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
36
web/src/lib/stores/preview.svelte.ts
Normal file
36
web/src/lib/stores/preview.svelte.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Single-photo preview overlay state. The lightbox sits in +layout.svelte
|
||||||
|
* and listens to this store; any view (timeline, duplicates view, heaps)
|
||||||
|
* can `open(uid)` to pop it. The order array is mirrored from whatever
|
||||||
|
* list the user is currently looking at so prev/next stay in context.
|
||||||
|
*/
|
||||||
|
export const preview = $state<{
|
||||||
|
uid: string | null;
|
||||||
|
order: string[];
|
||||||
|
}>({
|
||||||
|
uid: null,
|
||||||
|
order: []
|
||||||
|
});
|
||||||
|
|
||||||
|
export function openPreview(uid: string, order?: string[]): void {
|
||||||
|
if (order) preview.order = order;
|
||||||
|
preview.uid = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePreview(): void {
|
||||||
|
preview.uid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewNext(): void {
|
||||||
|
if (!preview.uid) return;
|
||||||
|
const i = preview.order.indexOf(preview.uid);
|
||||||
|
if (i < 0 || i >= preview.order.length - 1) return;
|
||||||
|
preview.uid = preview.order[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewPrev(): void {
|
||||||
|
if (!preview.uid) return;
|
||||||
|
const i = preview.order.indexOf(preview.uid);
|
||||||
|
if (i <= 0) return;
|
||||||
|
preview.uid = preview.order[i - 1];
|
||||||
|
}
|
||||||
115
web/src/lib/stores/selection.svelte.ts
Normal file
115
web/src/lib/stores/selection.svelte.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select state for the timeline + duplicate views. Tracks which photo
|
||||||
|
* UIDs are picked, plus an anchor for shift-range extension and a focus
|
||||||
|
* cursor for arrow-key navigation. Components import `selection` and read
|
||||||
|
* its reactive fields; mutations go through the helpers below.
|
||||||
|
*
|
||||||
|
* SvelteSet is required (not plain Set) so component-level `selection.ids`
|
||||||
|
* reads re-run when membership changes.
|
||||||
|
*/
|
||||||
|
export const selection = $state<{
|
||||||
|
ids: SvelteSet<string>;
|
||||||
|
anchor: string | null;
|
||||||
|
focused: string | null;
|
||||||
|
/**
|
||||||
|
* Mirror of the active ordered photo list, kept in sync by the timeline.
|
||||||
|
* Needed for shift-range extension and arrow-key navigation.
|
||||||
|
*/
|
||||||
|
order: string[];
|
||||||
|
}>({
|
||||||
|
ids: new SvelteSet<string>(),
|
||||||
|
anchor: null,
|
||||||
|
focused: null,
|
||||||
|
order: []
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side index of `order`. Rebuilt by `setOrder`. Keeping the array around
|
||||||
|
* (rather than dropping it entirely) lets preview navigation and
|
||||||
|
* `cullTargets` iterate cheaply; the map exists solely to take
|
||||||
|
* `selectRange` and arrow-key navigation from O(n) to O(1) on large
|
||||||
|
* libraries. Not reactive — only `setOrder` reads/writes it.
|
||||||
|
*/
|
||||||
|
const orderIndex = new Map<string, number>();
|
||||||
|
|
||||||
|
/** O(1) index lookup. Returns -1 when the uid isn't in the current order
|
||||||
|
* (consistent with `Array.indexOf`). */
|
||||||
|
export function indexOf(uid: string | null): number {
|
||||||
|
if (uid === null) return -1;
|
||||||
|
const i = orderIndex.get(uid);
|
||||||
|
return i === undefined ? -1 : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelected(uid: string): boolean {
|
||||||
|
return selection.ids.has(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelection(): void {
|
||||||
|
selection.ids.clear();
|
||||||
|
selection.anchor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggle(uid: string): void {
|
||||||
|
if (selection.ids.has(uid)) {
|
||||||
|
selection.ids.delete(uid);
|
||||||
|
} else {
|
||||||
|
selection.ids.add(uid);
|
||||||
|
selection.anchor = uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectOnly(uid: string): void {
|
||||||
|
selection.ids.clear();
|
||||||
|
selection.ids.add(uid);
|
||||||
|
selection.anchor = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectRange(uid: string): void {
|
||||||
|
// When the user hasn't explicitly anchored (no toggle/selectOnly before
|
||||||
|
// this shift-click), treat the focused tile as the anchor — that's the
|
||||||
|
// "starting photo" the user just clicked or arrow-keyed to. Without this
|
||||||
|
// fallback, shift-clicking after a plain click would select only the
|
||||||
|
// shift-clicked tile and the starting photo would be dropped.
|
||||||
|
const anchor = selection.anchor ?? selection.focused;
|
||||||
|
if (!anchor) {
|
||||||
|
selectOnly(uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = indexOf(anchor);
|
||||||
|
const b = indexOf(uid);
|
||||||
|
if (a < 0 || b < 0) {
|
||||||
|
selectOnly(uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||||
|
selection.ids.clear();
|
||||||
|
for (let i = lo; i <= hi; i++) selection.ids.add(selection.order[i]);
|
||||||
|
// Promote the anchor we used so subsequent shift-clicks keep the same
|
||||||
|
// start point (otherwise focus-as-anchor would drift each move).
|
||||||
|
selection.anchor = anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOrder(order: string[]): void {
|
||||||
|
selection.order = order;
|
||||||
|
// Rebuild the side index. `clear` + per-element `set` is O(n) and
|
||||||
|
// allocation-free vs `new Map(order.map(...))` which would churn GC
|
||||||
|
// on every page append in the infinite-scroll path.
|
||||||
|
orderIndex.clear();
|
||||||
|
for (let i = 0; i < order.length; i++) orderIndex.set(order[i], i);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFocused(uid: string | null): void {
|
||||||
|
selection.focused = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote a uid to the shift-range anchor without adding it to the selection.
|
||||||
|
* Used on plain click + arrow-key navigation so the next shift-click extends
|
||||||
|
* from the user's most recent interaction (the "starting photo"), even when
|
||||||
|
* the selection set is empty.
|
||||||
|
*/
|
||||||
|
export function setAnchor(uid: string | null): void {
|
||||||
|
selection.anchor = uid;
|
||||||
|
}
|
||||||
89
web/src/lib/stores/session.svelte.ts
Normal file
89
web/src/lib/stores/session.svelte.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'pp_session';
|
||||||
|
|
||||||
|
interface PersistedSession {
|
||||||
|
id: string;
|
||||||
|
accessToken: string;
|
||||||
|
previewToken: string;
|
||||||
|
downloadToken: string;
|
||||||
|
user: PpUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInitial(): PersistedSession | null {
|
||||||
|
if (!browser) return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as PersistedSession;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-source session state for the Svelte client. Components import the
|
||||||
|
* `session` object and read its reactive fields; mutations go through the
|
||||||
|
* helpers below. State is mirrored to localStorage so a hard refresh keeps
|
||||||
|
* the user signed in.
|
||||||
|
*/
|
||||||
|
const initial = loadInitial();
|
||||||
|
|
||||||
|
export const session = $state<{
|
||||||
|
id: string | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
previewToken: string | null;
|
||||||
|
downloadToken: string | null;
|
||||||
|
user: PpUser | null;
|
||||||
|
}>({
|
||||||
|
id: initial?.id ?? null,
|
||||||
|
accessToken: initial?.accessToken ?? null,
|
||||||
|
previewToken: initial?.previewToken ?? null,
|
||||||
|
downloadToken: initial?.downloadToken ?? null,
|
||||||
|
user: initial?.user ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return Boolean(session.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void {
|
||||||
|
session.id = resp.id;
|
||||||
|
session.accessToken = resp.access_token;
|
||||||
|
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
|
||||||
|
session.downloadToken = (cfg ?? resp.config)?.downloadToken ?? '';
|
||||||
|
session.user = resp.user;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(): void {
|
||||||
|
session.id = null;
|
||||||
|
session.accessToken = null;
|
||||||
|
session.previewToken = null;
|
||||||
|
session.downloadToken = null;
|
||||||
|
session.user = null;
|
||||||
|
if (browser) localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
if (!browser || !session.accessToken) return;
|
||||||
|
const payload: PersistedSession = {
|
||||||
|
id: session.id ?? '',
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
previewToken: session.previewToken ?? '',
|
||||||
|
downloadToken: session.downloadToken ?? '',
|
||||||
|
user: session.user!
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a thumbnail URL for a photo. PhotoPrism's thumb endpoint is
|
||||||
|
* /api/v1/t/:hash/:token/:size — the token is the per-session
|
||||||
|
* previewToken, which the session response provides on login.
|
||||||
|
*/
|
||||||
|
export function thumbUrl(hash: string, size = 'tile_500'): string {
|
||||||
|
if (!session.previewToken) return '';
|
||||||
|
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
|
||||||
|
}
|
||||||
44
web/src/lib/stores/undo.svelte.ts
Normal file
44
web/src/lib/stores/undo.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* LIFO stack of undoable actions. Each push registers an inverse callback;
|
||||||
|
* pop runs the most recent inverse and removes it from the stack. Keep the
|
||||||
|
* UI feedback honest: undo entries are best-effort and exact restoration
|
||||||
|
* isn't always possible (e.g. when the inverse depends on server state that
|
||||||
|
* has since changed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UndoEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
pushedAt: number;
|
||||||
|
undo(): Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 25;
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
export const undoStack = $state<{ entries: UndoEntry[] }>({ entries: [] });
|
||||||
|
|
||||||
|
export function push(label: string, undo: UndoEntry['undo']): UndoEntry {
|
||||||
|
const entry: UndoEntry = {
|
||||||
|
id: `u-${++counter}`,
|
||||||
|
label,
|
||||||
|
pushedAt: Date.now(),
|
||||||
|
undo
|
||||||
|
};
|
||||||
|
undoStack.entries.push(entry);
|
||||||
|
if (undoStack.entries.length > MAX_ENTRIES) {
|
||||||
|
undoStack.entries.shift();
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function popAndRun(): Promise<UndoEntry | null> {
|
||||||
|
const entry = undoStack.entries.pop();
|
||||||
|
if (!entry) return null;
|
||||||
|
await entry.undo();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clear(): void {
|
||||||
|
undoStack.entries.length = 0;
|
||||||
|
}
|
||||||
123
web/src/lib/stores/view.svelte.ts
Normal file
123
web/src/lib/stores/view.svelte.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View-level UI preferences. Persisted to localStorage so collapse state,
|
||||||
|
* thumb size, etc. survive a refresh. Same module is reused by the
|
||||||
|
* timeline page and the preview overlay so the sidebar toggle stays in
|
||||||
|
* sync across views (matches mule-image's "intelligent preview recall").
|
||||||
|
*/
|
||||||
|
const STORAGE_KEY = 'mule_view';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors mule-image's mule-image-viewSettingsStore thumbnail presets so the
|
||||||
|
* UX scales the same way: five steps with `M` as the default. Labels are
|
||||||
|
* cosmetic; the numbers feed the `minmax(<size>px, 1fr)` grid template.
|
||||||
|
*/
|
||||||
|
export const THUMBNAIL_SIZE_PRESETS = [96, 128, 160, 208, 272] as const;
|
||||||
|
export type ThumbnailSize = (typeof THUMBNAIL_SIZE_PRESETS)[number];
|
||||||
|
export const THUMBNAIL_SIZE_LABELS = ['XS', 'S', 'M', 'L', 'XL'] as const;
|
||||||
|
export const DEFAULT_THUMBNAIL_SIZE: ThumbnailSize = 160;
|
||||||
|
|
||||||
|
interface Persisted {
|
||||||
|
rightSidebarCollapsed?: boolean;
|
||||||
|
leftSidebarCollapsed?: boolean;
|
||||||
|
thumbnailSize?: ThumbnailSize;
|
||||||
|
leftSidebarWidth?: number;
|
||||||
|
rightSidebarWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MIN_LEFT_WIDTH = 180;
|
||||||
|
export const MAX_LEFT_WIDTH = 480;
|
||||||
|
export const DEFAULT_LEFT_WIDTH = 224;
|
||||||
|
export const MIN_RIGHT_WIDTH = 220;
|
||||||
|
export const MAX_RIGHT_WIDTH = 480;
|
||||||
|
export const DEFAULT_RIGHT_WIDTH = 280;
|
||||||
|
|
||||||
|
function clamp(n: number, lo: number, hi: number): number {
|
||||||
|
return Math.min(hi, Math.max(lo, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThumbnailSize(n: unknown): n is ThumbnailSize {
|
||||||
|
return (
|
||||||
|
typeof n === 'number' &&
|
||||||
|
(THUMBNAIL_SIZE_PRESETS as readonly number[]).includes(n)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInitial(): Persisted {
|
||||||
|
if (!browser) return {};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as Persisted) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = loadInitial();
|
||||||
|
|
||||||
|
export const view = $state<{
|
||||||
|
rightSidebarCollapsed: boolean;
|
||||||
|
leftSidebarCollapsed: boolean;
|
||||||
|
thumbnailSize: ThumbnailSize;
|
||||||
|
leftSidebarWidth: number;
|
||||||
|
rightSidebarWidth: number;
|
||||||
|
}>({
|
||||||
|
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||||
|
leftSidebarCollapsed: initial.leftSidebarCollapsed ?? false,
|
||||||
|
thumbnailSize: isThumbnailSize(initial.thumbnailSize)
|
||||||
|
? initial.thumbnailSize
|
||||||
|
: DEFAULT_THUMBNAIL_SIZE,
|
||||||
|
leftSidebarWidth: clamp(
|
||||||
|
typeof initial.leftSidebarWidth === 'number' ? initial.leftSidebarWidth : DEFAULT_LEFT_WIDTH,
|
||||||
|
MIN_LEFT_WIDTH,
|
||||||
|
MAX_LEFT_WIDTH
|
||||||
|
),
|
||||||
|
rightSidebarWidth: clamp(
|
||||||
|
typeof initial.rightSidebarWidth === 'number' ? initial.rightSidebarWidth : DEFAULT_RIGHT_WIDTH,
|
||||||
|
MIN_RIGHT_WIDTH,
|
||||||
|
MAX_RIGHT_WIDTH
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const payload: Persisted = {
|
||||||
|
rightSidebarCollapsed: view.rightSidebarCollapsed,
|
||||||
|
leftSidebarCollapsed: view.leftSidebarCollapsed,
|
||||||
|
thumbnailSize: view.thumbnailSize,
|
||||||
|
leftSidebarWidth: view.leftSidebarWidth,
|
||||||
|
rightSidebarWidth: view.rightSidebarWidth
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLeftSidebarWidth(px: number): void {
|
||||||
|
view.leftSidebarWidth = clamp(Math.round(px), MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRightSidebarWidth(px: number): void {
|
||||||
|
view.rightSidebarWidth = clamp(Math.round(px), MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||||
|
view.thumbnailSize = size;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleRightSidebar(): void {
|
||||||
|
view.rightSidebarCollapsed = !view.rightSidebarCollapsed;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRightSidebarCollapsed(collapsed: boolean): void {
|
||||||
|
view.rightSidebarCollapsed = collapsed;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleLeftSidebar(): void {
|
||||||
|
view.leftSidebarCollapsed = !view.leftSidebarCollapsed;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
203
web/src/lib/types/photoprism.ts
Normal file
203
web/src/lib/types/photoprism.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* PhotoPrism API response shapes — only the fields the Svelte client uses.
|
||||||
|
* Keep this surface narrow; extend as the UI grows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PpRole = 'admin' | 'user' | 'contributor' | 'guest' | 'visitor' | string;
|
||||||
|
|
||||||
|
export interface PpUser {
|
||||||
|
UID: string;
|
||||||
|
Name: string;
|
||||||
|
DisplayName?: string;
|
||||||
|
Email?: string;
|
||||||
|
Role: PpRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpClientConfig {
|
||||||
|
mode: 'public' | 'user';
|
||||||
|
name: string;
|
||||||
|
edition: string;
|
||||||
|
version: string;
|
||||||
|
siteUrl: string;
|
||||||
|
siteTitle: string;
|
||||||
|
previewToken: string;
|
||||||
|
downloadToken: string;
|
||||||
|
flags?: string;
|
||||||
|
count?: {
|
||||||
|
photos?: number;
|
||||||
|
videos?: number;
|
||||||
|
albums?: number;
|
||||||
|
labels?: number;
|
||||||
|
people?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpSessionResponse {
|
||||||
|
id: string;
|
||||||
|
access_token: string;
|
||||||
|
user: PpUser;
|
||||||
|
config: PpClientConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpPhoto {
|
||||||
|
UID: string;
|
||||||
|
/**
|
||||||
|
* `Hash` is only present on the list endpoint (`/photos?...`). The
|
||||||
|
* single-photo endpoint (`/photos/:uid`) returns the file hash nested
|
||||||
|
* under `Files[i].Hash` — use `primaryFile(photo).Hash` instead of
|
||||||
|
* reading this field unconditionally.
|
||||||
|
*/
|
||||||
|
Hash?: string;
|
||||||
|
/** Auto-derived display name (no extension). */
|
||||||
|
Name?: string;
|
||||||
|
/** PhotoPrism filename + extension, populated on list responses. */
|
||||||
|
FileName?: string;
|
||||||
|
/** User-editable original/preferred name. Persisted in DB + sidecar. */
|
||||||
|
OriginalName?: string;
|
||||||
|
Title?: string;
|
||||||
|
TitleSrc?: string;
|
||||||
|
Description?: string;
|
||||||
|
Caption?: string;
|
||||||
|
CaptionSrc?: string;
|
||||||
|
TakenAt?: string;
|
||||||
|
TakenAtLocal?: string;
|
||||||
|
TakenSrc?: string;
|
||||||
|
Year?: number;
|
||||||
|
Month?: number;
|
||||||
|
Day?: number;
|
||||||
|
/** Top-level Width/Height appear on list responses but not on detail. */
|
||||||
|
Width?: number;
|
||||||
|
Height?: number;
|
||||||
|
Rating?: number;
|
||||||
|
Color?: string | number;
|
||||||
|
Favorite?: boolean;
|
||||||
|
Private?: boolean;
|
||||||
|
Archived?: boolean;
|
||||||
|
Files?: PpFile[];
|
||||||
|
Lat?: number;
|
||||||
|
Lng?: number;
|
||||||
|
Altitude?: number;
|
||||||
|
Country?: string;
|
||||||
|
CountrySrc?: string;
|
||||||
|
TimeZone?: string;
|
||||||
|
Iso?: number;
|
||||||
|
FNumber?: number;
|
||||||
|
FocalLength?: number;
|
||||||
|
Exposure?: string;
|
||||||
|
Quality?: number;
|
||||||
|
Type?: string;
|
||||||
|
Camera?: PpCamera;
|
||||||
|
CameraID?: number;
|
||||||
|
CameraSrc?: string;
|
||||||
|
Lens?: PpLens;
|
||||||
|
LensID?: number;
|
||||||
|
Place?: PpPlace;
|
||||||
|
PlaceID?: string;
|
||||||
|
PlaceSrc?: string;
|
||||||
|
Details?: PpDetails;
|
||||||
|
Labels?: PpPhotoLabel[];
|
||||||
|
IndexedAt?: string;
|
||||||
|
EditedAt?: string;
|
||||||
|
UpdatedAt?: string;
|
||||||
|
CreatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpCamera {
|
||||||
|
ID?: number;
|
||||||
|
Slug?: string;
|
||||||
|
Name?: string;
|
||||||
|
Make?: string;
|
||||||
|
Model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpLens {
|
||||||
|
ID?: number;
|
||||||
|
Slug?: string;
|
||||||
|
Name?: string;
|
||||||
|
Make?: string;
|
||||||
|
Model?: string;
|
||||||
|
Type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpPlace {
|
||||||
|
ID?: string;
|
||||||
|
Label?: string;
|
||||||
|
PlaceLabel?: string;
|
||||||
|
City?: string;
|
||||||
|
State?: string;
|
||||||
|
Country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpDetails {
|
||||||
|
PhotoID?: number;
|
||||||
|
Keywords?: string;
|
||||||
|
KeywordsSrc?: string;
|
||||||
|
Notes?: string;
|
||||||
|
NotesSrc?: string;
|
||||||
|
Subject?: string;
|
||||||
|
SubjectSrc?: string;
|
||||||
|
Artist?: string;
|
||||||
|
ArtistSrc?: string;
|
||||||
|
Copyright?: string;
|
||||||
|
CopyrightSrc?: string;
|
||||||
|
License?: string;
|
||||||
|
LicenseSrc?: string;
|
||||||
|
Software?: string;
|
||||||
|
SoftwareSrc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpPhotoLabel {
|
||||||
|
UID?: string;
|
||||||
|
Source?: string;
|
||||||
|
Priority?: number;
|
||||||
|
Uncertainty?: number;
|
||||||
|
Label?: { Slug: string; Name: string; Favorite?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the photo's primary file (the one with `Primary: true`) or the
|
||||||
|
* first file if no primary marker is set. Falls back to a synthetic entry
|
||||||
|
* that surfaces the top-level Hash so list-shape photos still resolve.
|
||||||
|
*/
|
||||||
|
export function primaryFile(p: PpPhoto): PpFile {
|
||||||
|
const files = p.Files ?? [];
|
||||||
|
const primary = files.find((f) => f.Primary) ?? files[0];
|
||||||
|
if (primary) return primary;
|
||||||
|
return {
|
||||||
|
UID: p.UID,
|
||||||
|
Hash: p.Hash ?? '',
|
||||||
|
Name: p.FileName ?? p.Name ?? '',
|
||||||
|
Root: '/',
|
||||||
|
Primary: true,
|
||||||
|
Width: p.Width,
|
||||||
|
Height: p.Height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpFile {
|
||||||
|
UID: string;
|
||||||
|
Hash: string;
|
||||||
|
Name: string;
|
||||||
|
Root: string;
|
||||||
|
Primary?: boolean;
|
||||||
|
Stack?: number;
|
||||||
|
Width?: number;
|
||||||
|
Height?: number;
|
||||||
|
Size?: number;
|
||||||
|
FileType?: string;
|
||||||
|
MediaType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PpThumbSize =
|
||||||
|
| 'tile_50'
|
||||||
|
| 'tile_100'
|
||||||
|
| 'tile_224'
|
||||||
|
| 'tile_500'
|
||||||
|
| 'fit_720'
|
||||||
|
| 'fit_1280'
|
||||||
|
| 'fit_1920'
|
||||||
|
| 'fit_2048'
|
||||||
|
| 'fit_2560'
|
||||||
|
| 'fit_3840'
|
||||||
|
| 'fit_4096'
|
||||||
|
| 'fit_7680';
|
||||||
10
web/src/lib/utils.ts
Normal file
10
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shadcn-svelte's canonical class composer: merge tailwind classes,
|
||||||
|
* de-duplicate conflicts (last one wins for the same utility group).
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
84
web/src/routes/+layout.svelte
Normal file
84
web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||||
|
import { resizable } from '$lib/actions/resizable';
|
||||||
|
import { queryClient } from '$lib/queryClient';
|
||||||
|
import PreviewOverlay from '$lib/components/preview/PreviewOverlay.svelte';
|
||||||
|
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||||
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
// Auth guard. Anything outside /login requires a session; otherwise
|
||||||
|
// punt to the login page (which itself redirects authenticated users
|
||||||
|
// back to /).
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const onLogin = page.url.pathname === '/login';
|
||||||
|
if (!isAuthenticated() && !onLogin) {
|
||||||
|
void goto('/login', { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Mulimage</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<ModeWatcher />
|
||||||
|
<Toaster richColors position="bottom-right" />
|
||||||
|
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{#if isAuthenticated() && page.url.pathname !== '/login'}
|
||||||
|
<!-- App shell locks to viewport height; only the main thumbnail
|
||||||
|
region inside each page scrolls. The mule header, toolbar, and
|
||||||
|
sidebars stay fixed regardless of how far you scroll the grid. -->
|
||||||
|
<div class="flex h-screen flex-col overflow-hidden">
|
||||||
|
<AnimatedMule />
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
{#if !view.leftSidebarCollapsed}
|
||||||
|
<aside
|
||||||
|
class="relative hidden h-full shrink-0 border-r border-border bg-card/30 md:block"
|
||||||
|
style="width: {view.leftSidebarWidth}px;"
|
||||||
|
>
|
||||||
|
<div class="h-full overflow-y-auto p-3">
|
||||||
|
<LeftSidebar />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="group absolute -right-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize md:block"
|
||||||
|
use:resizable={{
|
||||||
|
edge: 'right',
|
||||||
|
getWidth: () => view.leftSidebarWidth,
|
||||||
|
setWidth: setLeftSidebarWidth
|
||||||
|
}}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize left panel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
<!-- Right column hosts the route's content. Pages render as
|
||||||
|
a flex column whose first child is the Toolbar (shrink-0)
|
||||||
|
and whose remaining content takes the rest, so each route
|
||||||
|
can decide which of its panes is the scrollable one. -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
|
<PreviewOverlay />
|
||||||
|
</QueryClientProvider>
|
||||||
4
web/src/routes/+layout.ts
Normal file
4
web/src/routes/+layout.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// SPA mode: disable SSR / prerendering across the app. The Svelte client
|
||||||
|
// talks directly to PhotoPrism's REST + WebSocket; no server runtime needed.
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
871
web/src/routes/+page.svelte
Normal file
871
web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toggleMode, mode } from 'mode-watcher';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
batchDelete,
|
||||||
|
getPhoto,
|
||||||
|
listHeaps,
|
||||||
|
listPhotos,
|
||||||
|
logout,
|
||||||
|
type PpAlbum
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import {
|
||||||
|
filters,
|
||||||
|
filtersToQ,
|
||||||
|
filtersToUrlParams,
|
||||||
|
parseUrlParams,
|
||||||
|
setSearch,
|
||||||
|
setSection
|
||||||
|
} from '$lib/stores/filters.svelte';
|
||||||
|
import { isAuthenticated, session, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import {
|
||||||
|
isSelected,
|
||||||
|
selectRange,
|
||||||
|
selection,
|
||||||
|
setAnchor,
|
||||||
|
setFocused,
|
||||||
|
setOrder
|
||||||
|
} from '$lib/stores/selection.svelte';
|
||||||
|
import { openPreview } from '$lib/stores/preview.svelte';
|
||||||
|
import {
|
||||||
|
setRightSidebarWidth,
|
||||||
|
setThumbnailSize,
|
||||||
|
THUMBNAIL_SIZE_LABELS,
|
||||||
|
THUMBNAIL_SIZE_PRESETS,
|
||||||
|
view
|
||||||
|
} from '$lib/stores/view.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { resizable } from '$lib/actions/resizable';
|
||||||
|
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
|
||||||
|
import { nearBottom } from '$lib/actions/nearBottom';
|
||||||
|
import {
|
||||||
|
visibleRange,
|
||||||
|
getVisibleRangeHandle,
|
||||||
|
type VisibleRangeHandle
|
||||||
|
} from '$lib/actions/visibleRange';
|
||||||
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
// ── URL ↔ filter store sync ──────────────────────────────────────────────
|
||||||
|
// On nav (back/forward, deep link), reflect the URL into the store.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const next = parseUrlParams(page.url.searchParams);
|
||||||
|
if (next.section !== undefined) filters.section = next.section;
|
||||||
|
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||||
|
if (next.search !== undefined) filters.search = next.search;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the store changes from in-app actions (left-sidebar nav, search
|
||||||
|
// box, etc.), push the matching query string back so the URL is shareable.
|
||||||
|
let lastWritten = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const qs = filtersToUrlParams().toString();
|
||||||
|
const here = page.url.searchParams.toString();
|
||||||
|
if (qs === here || qs === lastWritten) return;
|
||||||
|
lastWritten = qs;
|
||||||
|
void goto(`/${qs ? '?' + qs : ''}`, {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Section title ────────────────────────────────────────────────────────
|
||||||
|
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||||||
|
queryKey: ['heaps'],
|
||||||
|
queryFn: listHeaps,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sectionLabel = $derived(buildSectionLabel());
|
||||||
|
function buildSectionLabel(): string {
|
||||||
|
switch (filters.section) {
|
||||||
|
case 'favorites':
|
||||||
|
return 'Favorites';
|
||||||
|
case 'archive':
|
||||||
|
return 'Archive';
|
||||||
|
case 'heap': {
|
||||||
|
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
||||||
|
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 'All photos';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo list ───────────────────────────────────────────────────────────
|
||||||
|
// Infinite scroll. PhotoPrism's `/photos?merged=true` returns a multi-row-
|
||||||
|
// per-photo shape (HEIC + companion JPG + MOV each count toward `count`),
|
||||||
|
// so the page size is generous: 120 photo entries per page lands ~250-360
|
||||||
|
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
||||||
|
// downstream into a single `photos` array consumers iterate.
|
||||||
|
const PHOTOS_PAGE_SIZE = 120;
|
||||||
|
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
||||||
|
queryKey: ['photos', 'q', filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
listPhotos({
|
||||||
|
q: filtersToQ(filters),
|
||||||
|
count: PHOTOS_PAGE_SIZE,
|
||||||
|
offset: pageParam as number,
|
||||||
|
order: 'newest',
|
||||||
|
merged: true
|
||||||
|
}),
|
||||||
|
initialPageParam: 0,
|
||||||
|
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
|
||||||
|
// photo expands into its file rows, so a "full" page of count=120
|
||||||
|
// typically returns ~60 photo entries. The only reliable end-of-
|
||||||
|
// pagination signal is an empty page. Costs one extra fetch at the
|
||||||
|
// tail (cheap; the empty response is small).
|
||||||
|
getNextPageParam: (last, pages) =>
|
||||||
|
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
|
||||||
|
* pages can repeat a photo when its file-row span straddles the offset
|
||||||
|
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||||
|
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
||||||
|
* handlers) treat this as the single source of truth. */
|
||||||
|
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
||||||
|
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||||
|
if (!pages) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: PpPhoto[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const p of page) {
|
||||||
|
if (seen.has(p.UID)) continue;
|
||||||
|
seen.add(p.UID);
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setOrder(photos.map((p) => p.UID));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-focus the first photo on the FIRST page only. Subsequent pages
|
||||||
|
* append silently — we never want the focus to jump back to the top of
|
||||||
|
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
|
||||||
|
* from immediately re-triggering this effect.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
// Re-read pageCount so the effect bottoms out cleanly on filter
|
||||||
|
// changes (which reset pageCount back to 0/1).
|
||||||
|
const pages = pageCount;
|
||||||
|
untrack(() => {
|
||||||
|
if (photos.length === 0) {
|
||||||
|
setFocused(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only re-anchor focus on the very first page; later pages
|
||||||
|
// must not pull focus back to photo[0].
|
||||||
|
if (pages !== 1) return;
|
||||||
|
const cur = selection.focused;
|
||||||
|
if (!cur || !photos.some((p) => p.UID === cur)) {
|
||||||
|
setFocused(photos[0].UID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten photos + month headers into a single row list. PhotoPrism
|
||||||
|
* returns photos pre-sorted by TakenAt; we emit a header row whenever
|
||||||
|
* the month-key changes, and tile rows for every photo in order. The
|
||||||
|
* tile's `tileIndex` matches its position in `photos`, which is what
|
||||||
|
* the windowing observer keys off of (so the visible-range math stays
|
||||||
|
* one-dimensional even though the template renders headers inline).
|
||||||
|
*/
|
||||||
|
type Row =
|
||||||
|
| { kind: 'header'; key: string; label: string; count: number }
|
||||||
|
| { kind: 'tile'; photo: PpPhoto; tileIndex: number };
|
||||||
|
const rows = $derived<Row[]>(buildRows(photos));
|
||||||
|
|
||||||
|
function buildRows(list: PpPhoto[]): Row[] {
|
||||||
|
const out: Row[] = [];
|
||||||
|
const fmt = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
// First pass: count per month — used by the header chip. Sparse
|
||||||
|
// Map: O(months) memory, O(photos) time, both trivial at 10k.
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
for (const p of list) {
|
||||||
|
const { key, label } = monthKey(p, fmt);
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
if (!labels.has(key)) labels.set(key, label);
|
||||||
|
}
|
||||||
|
// Second pass: emit rows in document order.
|
||||||
|
let prev = '';
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const p = list[i];
|
||||||
|
const { key } = monthKey(p, fmt);
|
||||||
|
if (key !== prev) {
|
||||||
|
out.push({ kind: 'header', key, label: labels.get(key) ?? '', count: counts.get(key) ?? 0 });
|
||||||
|
prev = key;
|
||||||
|
}
|
||||||
|
out.push({ kind: 'tile', photo: p, tileIndex: i });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthKey(p: PpPhoto, fmt: Intl.DateTimeFormat): { key: string; label: string } {
|
||||||
|
const raw = p.TakenAtLocal ?? p.TakenAt ?? '';
|
||||||
|
if (!raw) return { key: 'no-date', label: 'No date' };
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return { key: 'no-date', label: 'No date' };
|
||||||
|
const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
return { key: k, label: fmt.format(d) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visible-range window. The observer reports `first`/`last` based on
|
||||||
|
// sampled tiles (every 5th, PhotoPrism's value). We render tiles in
|
||||||
|
// [first - BUFFER, last + BUFFER] plus any `forcedExpand` index that
|
||||||
|
// keyboard scrollToIndex has demanded (so a focused tile that's
|
||||||
|
// currently windowed-out is mounted before scrollIntoView runs).
|
||||||
|
const TILE_BUFFER = 4;
|
||||||
|
const TILE_SAMPLE = 5;
|
||||||
|
// Seed `visLast` generously so the first paint already shows a screen-
|
||||||
|
// ful of tiles instead of a single buffer of 4. The IntersectionObserver
|
||||||
|
// narrows it on the next layout pass.
|
||||||
|
const INITIAL_VIS_LAST = 60;
|
||||||
|
let visFirst = $state(0);
|
||||||
|
let visLast = $state(INITIAL_VIS_LAST);
|
||||||
|
let forcedExpand = $state<number | null>(null);
|
||||||
|
const renderFirst = $derived(
|
||||||
|
Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER)
|
||||||
|
);
|
||||||
|
const renderLast = $derived(
|
||||||
|
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset the window when the filter key changes — old indices from the
|
||||||
|
// previous photo list otherwise pin renderFirst/renderLast outside the
|
||||||
|
// new shorter list and the grid renders empty. Track `filtersToQ` as
|
||||||
|
// the trigger; appending pages (which keeps the same filter key) does
|
||||||
|
// NOT reset, so scroll position stays stable mid-pagination.
|
||||||
|
$effect(() => {
|
||||||
|
filtersToQ(filters);
|
||||||
|
untrack(() => {
|
||||||
|
visFirst = 0;
|
||||||
|
visLast = INITIAL_VIS_LAST;
|
||||||
|
forcedExpand = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-tile register handle exposed by the visibleRange action. The
|
||||||
|
// host pulls it off the scroll-root node once after mount.
|
||||||
|
let visHandle: VisibleRangeHandle | null = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (scrollRoot) visHandle = getVisibleRangeHandle(scrollRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `use:tileRegister={i}` — stable-identity Svelte action that hooks
|
||||||
|
* the tile shell into the visibility observer when it mounts and
|
||||||
|
* un-hooks it when it unmounts (or when `i` changes because the
|
||||||
|
* photos array shifted). Using a `use:` action (not `{@attach}`)
|
||||||
|
* keeps the registration stable across re-renders; `{@attach}` would
|
||||||
|
* rebuild on every render because the inline arrow has fresh
|
||||||
|
* identity each time. */
|
||||||
|
function tileRegister(node: HTMLElement, index: number) {
|
||||||
|
let current = index;
|
||||||
|
visHandle?.register(node, current);
|
||||||
|
return {
|
||||||
|
update(next: number) {
|
||||||
|
if (next === current) return;
|
||||||
|
visHandle?.unregister(node);
|
||||||
|
current = next;
|
||||||
|
visHandle?.register(node, current);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
visHandle?.unregister(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll the given tile fully into view. The default `scrollIntoView`
|
||||||
|
* ignores the sticky month header overlaying the top of the grid, so
|
||||||
|
* arrow-up into the row immediately under a header would leave the
|
||||||
|
* tile occluded. We compute the visible region manually, subtract
|
||||||
|
* the sticky header height from the top, and add a 25% "peek" so
|
||||||
|
* the focused tile lands with breathing room rather than flush
|
||||||
|
* against the viewport edge — same heuristic mule-image's Timeline
|
||||||
|
* uses in its `scrollRowIntoView`. */
|
||||||
|
function scrollTileIntoView(el: HTMLElement) {
|
||||||
|
if (!scrollRoot) return;
|
||||||
|
const stickyH = scrollRoot.querySelector<HTMLElement>('h2.sticky')?.offsetHeight ?? 0;
|
||||||
|
const rootRect = scrollRoot.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
|
||||||
|
const elBot = elTop + elRect.height;
|
||||||
|
const peek = Math.round(elRect.height * 0.25);
|
||||||
|
const viewTop = scrollRoot.scrollTop + stickyH;
|
||||||
|
const viewBot = scrollRoot.scrollTop + scrollRoot.clientHeight;
|
||||||
|
if (elTop - peek < viewTop) {
|
||||||
|
scrollRoot.scrollTo({ top: Math.max(0, elTop - peek - stickyH) });
|
||||||
|
} else if (elBot + peek > viewBot) {
|
||||||
|
scrollRoot.scrollTo({ top: elBot + peek - scrollRoot.clientHeight });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when arrow-keying lands on a tile that isn't currently
|
||||||
|
* rendered. Forces the window to include the target, waits for the
|
||||||
|
* tile shell to mount, then scrolls it fully into view. */
|
||||||
|
async function scrollToIndex(i: number) {
|
||||||
|
forcedExpand = i;
|
||||||
|
await tick();
|
||||||
|
if (!scrollRoot) return;
|
||||||
|
const el = scrollRoot.querySelector<HTMLElement>(`[data-uid-shell="${photos[i]?.UID ?? ''}"]`);
|
||||||
|
if (el) scrollTileIntoView(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||||
|
// The CSS Grid lays each photo into a cell with column count derived from
|
||||||
|
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||||
|
// full row, so a new month forces a row break even if the previous month's
|
||||||
|
// last row had empty slots. Linear "+/-cols" arrow math doesn't account
|
||||||
|
// for that and crosses headers wrong; mule-image's `useGridKeyNav` solves
|
||||||
|
// it by operating on an explicit `string[][]` visual-row map. We do the
|
||||||
|
// same: build the row map from `photos` + `tilesPerRow` + month
|
||||||
|
// boundaries, then translate arrow keys into (row, col) moves.
|
||||||
|
|
||||||
|
/** Cached column count of the photo grid. Measured from
|
||||||
|
* `grid-template-columns` on the grid element itself (the only place
|
||||||
|
* with a reliable value) via a Svelte action that runs *after* the
|
||||||
|
* grid mounts. An earlier $effect-based version observed
|
||||||
|
* `scrollRoot` and ran before the {#if photos.length > 0} branch
|
||||||
|
* evaluated, so the grid element didn't exist and `cols` stayed at
|
||||||
|
* 1 — making the arrow-keyboard nav feel like a 1-column list. */
|
||||||
|
let cols = $state(1);
|
||||||
|
let gridEl: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
function trackGridCols(node: HTMLElement) {
|
||||||
|
gridEl = node;
|
||||||
|
const measure = () => {
|
||||||
|
const n = getComputedStyle(node).gridTemplateColumns.split(' ').filter(Boolean).length;
|
||||||
|
cols = Math.max(1, n);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
// ResizeObserver fires on width changes (sidebar toggle, window
|
||||||
|
// resize, container reflow). Thumbnail-size changes don't change
|
||||||
|
// the grid's width but DO change its column count — handled by a
|
||||||
|
// separate effect below that re-runs `measure` on the next tick.
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
ro.disconnect();
|
||||||
|
if (gridEl === node) gridEl = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail-size changes alter column count without resizing the grid,
|
||||||
|
// so the ResizeObserver above misses them. Re-measure on the next
|
||||||
|
// microtask so the new computed style is in place.
|
||||||
|
$effect(() => {
|
||||||
|
void view.thumbnailSize;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!gridEl) return;
|
||||||
|
const n = getComputedStyle(gridEl).gridTemplateColumns.split(' ').filter(Boolean).length;
|
||||||
|
cols = Math.max(1, n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface VisualRow {
|
||||||
|
uids: string[];
|
||||||
|
/** Index of the first tile in this row, in the flat `photos` array.
|
||||||
|
* Used to call `scrollToIndex` after a move. */
|
||||||
|
firstTileIndex: number;
|
||||||
|
}
|
||||||
|
/** Pre-broken visual rows + a `uid → (row, col)` index. Whenever a new
|
||||||
|
* month begins, the prior row is flushed regardless of how full it was
|
||||||
|
* — this matches the CSS Grid where a full-span header forces the
|
||||||
|
* next tile onto a fresh row. */
|
||||||
|
const visualGrid = $derived(buildVisualGrid(photos, cols));
|
||||||
|
|
||||||
|
function buildVisualGrid(
|
||||||
|
list: PpPhoto[],
|
||||||
|
colsPerRow: number
|
||||||
|
): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
|
||||||
|
const rows: VisualRow[] = [];
|
||||||
|
const pos = new Map<string, [number, number]>();
|
||||||
|
const fmt = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
let curMonth = '';
|
||||||
|
let curRow: VisualRow | null = null;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const p = list[i];
|
||||||
|
const { key } = monthKey(p, fmt);
|
||||||
|
const monthChanged = key !== curMonth;
|
||||||
|
const rowFull = curRow !== null && curRow.uids.length >= colsPerRow;
|
||||||
|
if (!curRow || monthChanged || rowFull) {
|
||||||
|
curRow = { uids: [], firstTileIndex: i };
|
||||||
|
rows.push(curRow);
|
||||||
|
curMonth = key;
|
||||||
|
}
|
||||||
|
pos.set(p.UID, [rows.length - 1, curRow.uids.length]);
|
||||||
|
curRow.uids.push(p.UID);
|
||||||
|
}
|
||||||
|
return { rows, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Intended" column for sticky-column behaviour. Updated by horizontal
|
||||||
|
// arrow presses; vertical presses look up the destination cell with
|
||||||
|
// this column clamped to the destination row's width — so traversing
|
||||||
|
// a partial row doesn't permanently drift your column. Reset whenever
|
||||||
|
// focus is established by a non-arrow path (click, Escape).
|
||||||
|
let intendedCol: number | null = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
// Auto-clear when focus is dropped (Escape, photos refetch, etc.).
|
||||||
|
if (selection.focused === null) intendedCol = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onArrow(key: ArrowKey, extending: boolean) {
|
||||||
|
const { rows, pos } = visualGrid;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
// Resolve starting (row, col). Without a focused tile, default to
|
||||||
|
// the top-left so the first ArrowDown/Right lands on the first
|
||||||
|
// real photo instead of doing nothing. The explicit tuple type
|
||||||
|
// keeps TS from widening `[number, number]` to `string | number`.
|
||||||
|
const cur = selection.focused ? pos.get(selection.focused) : undefined;
|
||||||
|
const start: [number, number] = cur ?? [0, -1];
|
||||||
|
let r = start[0];
|
||||||
|
let c = start[1];
|
||||||
|
|
||||||
|
// Bootstrap intendedCol from the current column so the first
|
||||||
|
// vertical move preserves whatever column the user is already on.
|
||||||
|
if (intendedCol === null) intendedCol = c < 0 ? 0 : c;
|
||||||
|
|
||||||
|
if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
||||||
|
c += key === 'ArrowRight' ? 1 : -1;
|
||||||
|
// Wrap across row boundaries (LeftArrow at col 0 → previous
|
||||||
|
// row's last col, RightArrow at last col → next row's col 0).
|
||||||
|
while (c < 0 && r > 0) {
|
||||||
|
r -= 1;
|
||||||
|
c = rows[r].uids.length - 1;
|
||||||
|
}
|
||||||
|
while (r < rows.length && c >= rows[r].uids.length) {
|
||||||
|
if (r === rows.length - 1) {
|
||||||
|
c = rows[r].uids.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
r += 1;
|
||||||
|
c = 0;
|
||||||
|
}
|
||||||
|
if (c < 0) c = 0;
|
||||||
|
// User explicitly chose this column → it's the new "home" for
|
||||||
|
// subsequent vertical moves.
|
||||||
|
intendedCol = c;
|
||||||
|
} else {
|
||||||
|
r += key === 'ArrowDown' ? 1 : -1;
|
||||||
|
if (r < 0) r = 0;
|
||||||
|
if (r >= rows.length) r = rows.length - 1;
|
||||||
|
// Vertical: prefer the intended column; clamp to destination
|
||||||
|
// row width so a narrower row doesn't fall off the edge.
|
||||||
|
// intendedCol is preserved so going down through narrow rows
|
||||||
|
// then back up returns to the original column.
|
||||||
|
const w = rows[r].uids.length;
|
||||||
|
c = Math.min(intendedCol, w - 1);
|
||||||
|
if (c < 0) c = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destUid = rows[r]?.uids[c];
|
||||||
|
if (!destUid) return;
|
||||||
|
|
||||||
|
setFocused(destUid);
|
||||||
|
if (!extending) setAnchor(destUid);
|
||||||
|
if (extending && selection.focused) selectRange(destUid);
|
||||||
|
|
||||||
|
// Try the cheap path first: rendered tile, our scroll helper that
|
||||||
|
// respects the sticky header. Fall back to scrollToIndex (windowing
|
||||||
|
// expand + scroll) when the destination is currently unmounted.
|
||||||
|
const tile = scrollRoot?.querySelector<HTMLElement>(`[data-uid="${destUid}"]`);
|
||||||
|
if (tile) {
|
||||||
|
scrollTileIntoView(tile);
|
||||||
|
} else {
|
||||||
|
const flatIdx = rows[r].firstTileIndex + c;
|
||||||
|
scrollToIndex(flatIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||||
|
queryKey: ['photo', selection.focused ?? ''],
|
||||||
|
queryFn: () =>
|
||||||
|
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||||
|
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||||
|
staleTime: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function onSignOut() {
|
||||||
|
await logout();
|
||||||
|
await goto('/login', { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
let emptyingArchive = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every archived photo and delete it in pages. The timeline's
|
||||||
|
* infinite query only holds what the user has scrolled; we re-query
|
||||||
|
* `archived:true` directly so even unloaded pages get cleared.
|
||||||
|
* PhotoPrism caps `count` at 1000 — pull the max each round so we
|
||||||
|
* spend one HTTP call per chunk.
|
||||||
|
*/
|
||||||
|
async function onEmptyArchive() {
|
||||||
|
if (emptyingArchive) return;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
'Permanently delete EVERY photo in the Archive? This cannot be undone.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyingArchive = true;
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const batch = await listPhotos({
|
||||||
|
q: 'archived:true',
|
||||||
|
count: 1000,
|
||||||
|
offset: 0,
|
||||||
|
order: 'newest',
|
||||||
|
merged: false
|
||||||
|
});
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
const uids = Array.from(new Set(batch.map((p) => p.UID)));
|
||||||
|
await batchDelete(uids);
|
||||||
|
total += uids.length;
|
||||||
|
}
|
||||||
|
toast.success(total === 0 ? 'Archive already empty' : `Deleted ${total}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Empty archive failed');
|
||||||
|
} finally {
|
||||||
|
emptyingArchive = false;
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTileClick(e: MouseEvent, uid: string) {
|
||||||
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
|
if (selection.ids.size > 0) return;
|
||||||
|
// Establish the "starting photo" so a subsequent shift-click extends
|
||||||
|
// the range from this tile. Reset both focus and anchor — anchor on
|
||||||
|
// its own would stick to an older toggle/selectOnly tile and the
|
||||||
|
// shift-range would silently use the wrong starting point. Also
|
||||||
|
// clear the sticky-column intent so the next arrow press anchors
|
||||||
|
// off the clicked tile's actual column.
|
||||||
|
setFocused(uid);
|
||||||
|
setAnchor(uid);
|
||||||
|
intendedCol = null;
|
||||||
|
openPreview(uid, photos.map((p) => p.UID));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
|
||||||
|
// the <main> element below; the sentinel's `root` references this so
|
||||||
|
// the observer measures intersections relative to the timeline pane
|
||||||
|
// (the page itself doesn't scroll).
|
||||||
|
let scrollRoot: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
let searchDraft = $state(filters.search);
|
||||||
|
$effect(() => {
|
||||||
|
searchDraft = filters.search;
|
||||||
|
});
|
||||||
|
function onSearchSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchDraft.trim());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar showRightToggle>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{sectionLabel}
|
||||||
|
</span>
|
||||||
|
{#if filters.section === 'archive' && photos.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||||
|
disabled={emptyingArchive}
|
||||||
|
onclick={onEmptyArchive}
|
||||||
|
title="Permanently delete every archived photo"
|
||||||
|
>
|
||||||
|
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder='Search · label:website / "vacation"'
|
||||||
|
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
bind:value={searchDraft}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
{#if filters.search}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
|
||||||
|
onclick={() => {
|
||||||
|
searchDraft = '';
|
||||||
|
setSearch('');
|
||||||
|
}}
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#snippet trailing()}
|
||||||
|
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
||||||
|
Persisted to localStorage via view.svelte.ts. -->
|
||||||
|
<div
|
||||||
|
class="flex items-center overflow-hidden rounded border border-border"
|
||||||
|
role="group"
|
||||||
|
aria-label="Thumbnail size"
|
||||||
|
>
|
||||||
|
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
||||||
|
class:bg-accent={view.thumbnailSize === size}
|
||||||
|
class:text-foreground={view.thumbnailSize === size}
|
||||||
|
class:text-muted-foreground={view.thumbnailSize !== size}
|
||||||
|
onclick={() => setThumbnailSize(size)}
|
||||||
|
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
||||||
|
>
|
||||||
|
{THUMBNAIL_SIZE_LABELS[i]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hidden text-[11px] text-muted-foreground sm:inline">
|
||||||
|
{session.user?.DisplayName ?? session.user?.Name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||||
|
onclick={toggleMode}
|
||||||
|
title="Toggle theme"
|
||||||
|
>
|
||||||
|
{mode.current === 'dark' ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||||
|
onclick={onSignOut}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<main
|
||||||
|
bind:this={scrollRoot}
|
||||||
|
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||||
|
use:gridKeyNav={{ scrollToIndex, onArrow }}
|
||||||
|
use:visibleRange={{
|
||||||
|
onChange: (f, l) => {
|
||||||
|
visFirst = f;
|
||||||
|
visLast = l;
|
||||||
|
},
|
||||||
|
sampleEvery: TILE_SAMPLE
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="p-6 pb-24">
|
||||||
|
{#if photosQuery.isPending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading photos…</p>
|
||||||
|
{:else if photosQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Failed to load photos: {photosQuery.error instanceof Error
|
||||||
|
? photosQuery.error.message
|
||||||
|
: 'unknown error'}
|
||||||
|
</p>
|
||||||
|
{:else if photos.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{#if filters.section === 'archive'}
|
||||||
|
Archive is empty.
|
||||||
|
{:else if filters.section === 'favorites'}
|
||||||
|
No favorites yet. Heart a photo to add it here.
|
||||||
|
{:else if filters.section === 'heap'}
|
||||||
|
This heap has no photos yet. Select some photos and use the bulk bar's
|
||||||
|
"+ Add to heap" button.
|
||||||
|
{:else}
|
||||||
|
No photos. Index a folder via PhotoPrism's reindex command.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-photo-grid
|
||||||
|
use:trackGridCols
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each rows as row (row.kind === 'header' ? `h:${row.key}` : `t:${row.photo.UID}`)}
|
||||||
|
{#if row.kind === 'header'}
|
||||||
|
<!-- col-span-full + position:sticky pins the month label to
|
||||||
|
the top of the scrolling main as the user passes through.
|
||||||
|
-mx-6 stretches the bar past the wrapper padding so it
|
||||||
|
reads edge-to-edge in the viewport. -->
|
||||||
|
<h2
|
||||||
|
class="sticky top-0 z-10 -mx-6 border-b border-border bg-background/95 px-6 py-2 text-xs font-semibold uppercase tracking-wide text-foreground/80 backdrop-blur"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
<span class="ml-2 text-[10px] font-normal text-muted-foreground">
|
||||||
|
{row.count}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{:else}
|
||||||
|
{@const photo = row.photo}
|
||||||
|
{@const i = row.tileIndex}
|
||||||
|
{@const inWindow = i >= renderFirst && i <= renderLast}
|
||||||
|
<!-- Shell: always rendered. Holds grid-cell space + the
|
||||||
|
stable `data-uid-shell` anchor that gridKeyNav.scrollToIndex
|
||||||
|
can query even when the inner button is windowed out. -->
|
||||||
|
<div
|
||||||
|
data-uid-shell={photo.UID}
|
||||||
|
class="aspect-square"
|
||||||
|
use:tileRegister={i}
|
||||||
|
>
|
||||||
|
{#if inWindow}
|
||||||
|
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||||
|
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
|
||||||
|
<!-- Selection animation ported from mule-image's PhotoThumbnail:
|
||||||
|
scale to 90% + blue ring with offset + blue tint overlay, all
|
||||||
|
driven by a springy `cubic-bezier(0.34, 1.56, 0.64, 1)` over
|
||||||
|
300ms. Crucially, the transition class is ONLY applied when
|
||||||
|
selected — dropping it on deselect snaps the photo back to
|
||||||
|
full size instantly instead of crawling back.
|
||||||
|
The keyboard-focused photo gets the same treatment, so the
|
||||||
|
arrow-key cursor reads as a "selection of one" (matches
|
||||||
|
mule-image, where focused == singular selection). -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-tile
|
||||||
|
data-uid={photo.UID}
|
||||||
|
onclick={(e) => onTileClick(e, photo.UID)}
|
||||||
|
class:scale-90={sel}
|
||||||
|
class:ring-2={sel}
|
||||||
|
class:ring-blue-500={sel}
|
||||||
|
class:ring-offset-2={sel}
|
||||||
|
class:ring-offset-background={sel}
|
||||||
|
class:transition-[transform,box-shadow]={sel}
|
||||||
|
class:duration-300={sel}
|
||||||
|
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
|
||||||
|
class="group relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(hash, 'tile_500')}
|
||||||
|
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
class:transition={!sel}
|
||||||
|
class:group-hover:scale-105={!sel}
|
||||||
|
/>
|
||||||
|
<!-- Blue tint overlay (mule-image's primary selection
|
||||||
|
signal): pointer-events-none so clicks still hit the
|
||||||
|
button beneath. Rendered after the image so it composites
|
||||||
|
on top; before the badges so a star/heart still reads. -->
|
||||||
|
{#if sel}
|
||||||
|
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||||
|
{/if}
|
||||||
|
{#if photo.Favorite}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
|
||||||
|
>♥</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sentinel: when this div nears the viewport we kick the next
|
||||||
|
page. The 4-viewport rootMargin (handled by the action) is
|
||||||
|
PhotoPrism's preload distance from page/photos.vue. -->
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4"
|
||||||
|
use:nearBottom={{
|
||||||
|
onHit: () => photosQuery.fetchNextPage(),
|
||||||
|
enabled: photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
|
||||||
|
root: scrollRoot
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{#if photosQuery.isFetchingNextPage}
|
||||||
|
<p class="py-3 text-center text-xs text-muted-foreground">Loading more…</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if !view.rightSidebarCollapsed}
|
||||||
|
<aside
|
||||||
|
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||||
|
style="width: {view.rightSidebarWidth}px;"
|
||||||
|
>
|
||||||
|
<div class="h-full overflow-y-auto">
|
||||||
|
{#if selection.ids.size >= 2}
|
||||||
|
<!-- Multi-select swaps to a bulk-edit panel: edits fan out across
|
||||||
|
the whole selection (note / date / keyword). The single-photo
|
||||||
|
metadata view returns when the user drops back to one. -->
|
||||||
|
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||||
|
{:else if focusedPhotoQuery.data}
|
||||||
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
|
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2 p-4 text-center">
|
||||||
|
<div class="text-xl">ⓘ</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a thumbnail
|
||||||
|
to view its metadata here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Resize handle on the left edge; mirrors the layout's left aside
|
||||||
|
hot-zone for symmetry. -->
|
||||||
|
<div
|
||||||
|
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||||
|
use:resizable={{
|
||||||
|
edge: 'left',
|
||||||
|
getWidth: () => view.rightSidebarWidth,
|
||||||
|
setWidth: setRightSidebarWidth
|
||||||
|
}}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize info panel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
180
web/src/routes/colors/+page.svelte
Normal file
180
web/src/routes/colors/+page.svelte
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
getAllMarks,
|
||||||
|
listPhotos,
|
||||||
|
type PhotoMarksMap
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { openPreview } from '$lib/stores/preview.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
|
// PhotoPrism's Color is auto-derived from image content — the user-set
|
||||||
|
// label lives in mule-sidecar's marks map alongside ratings. Pool the
|
||||||
|
// recent photo list so we can resolve thumbnail hashes for each labelled
|
||||||
|
// UID. Matches the four-swatch palette used by RightSidebar.
|
||||||
|
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||||
|
queryKey: ['marks'],
|
||||||
|
queryFn: getAllMarks,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photosQuery = createQuery<PpPhoto[]>(() => ({
|
||||||
|
queryKey: ['photos', 'colors-pool'],
|
||||||
|
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||||
|
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||||
|
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||||
|
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||||
|
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ColorGroup {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
bg: string;
|
||||||
|
photos: PpPhoto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = $derived<ColorGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
|
||||||
|
|
||||||
|
function buildGroups(
|
||||||
|
marks: PhotoMarksMap | undefined,
|
||||||
|
pool: PpPhoto[] | undefined
|
||||||
|
): ColorGroup[] {
|
||||||
|
if (!marks || !pool) return [];
|
||||||
|
const byUid = new Map(pool.map((p) => [p.UID, p]));
|
||||||
|
const buckets = new Map<string, PpPhoto[]>();
|
||||||
|
for (const [uid, mark] of Object.entries(marks)) {
|
||||||
|
const c = mark.color;
|
||||||
|
if (!c) continue;
|
||||||
|
const photo = byUid.get(uid);
|
||||||
|
if (!photo) continue;
|
||||||
|
const arr = buckets.get(c) ?? [];
|
||||||
|
arr.push(photo);
|
||||||
|
buckets.set(c, arr);
|
||||||
|
}
|
||||||
|
const out: ColorGroup[] = [];
|
||||||
|
for (const swatch of COLOR_SWATCHES) {
|
||||||
|
const photos = buckets.get(swatch.key);
|
||||||
|
if (photos && photos.length > 0) {
|
||||||
|
out.push({ ...swatch, photos });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = $state<string | null>(null);
|
||||||
|
const selectedGroup = $derived(
|
||||||
|
selected !== null ? groups.find((g) => g.key === selected) ?? null : null
|
||||||
|
);
|
||||||
|
|
||||||
|
function pickGroup(key: string) {
|
||||||
|
selected = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selected = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Colors
|
||||||
|
</span>
|
||||||
|
{#if selectedGroup}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||||
|
onclick={clearSelection}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span class="flex items-center gap-1.5 text-[11px] font-medium">
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full {selectedGroup.bg}"></span>
|
||||||
|
{selectedGroup.title}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{groups.length} color{groups.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
{#if marksQuery.isPending || photosQuery.isPending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||||
|
{:else if marksQuery.isError || photosQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">Failed to load colors.</p>
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
No color labels yet. Open a photo and use the four-swatch row in the right
|
||||||
|
sidebar to tag it.
|
||||||
|
</p>
|
||||||
|
{:else if selectedGroup}
|
||||||
|
<div
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each selectedGroup.photos as photo (photo.UID)}
|
||||||
|
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() =>
|
||||||
|
openPreview(
|
||||||
|
photo.UID,
|
||||||
|
selectedGroup.photos.map((p) => p.UID)
|
||||||
|
)}
|
||||||
|
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(hash, 'tile_500')}
|
||||||
|
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid gap-3"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each groups as group (group.key)}
|
||||||
|
{@const rep = group.photos[0]}
|
||||||
|
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||||
|
onclick={() => pickGroup(group.key)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(hash, 'tile_500')}
|
||||||
|
alt={group.title}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5 truncate font-medium">
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full {group.bg}"></span>
|
||||||
|
{group.title}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
67
web/src/routes/duplicates/+page.svelte
Normal file
67
web/src/routes/duplicates/+page.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
listDuplicateGroups,
|
||||||
|
type DuplicateGroup
|
||||||
|
} from '$lib/services/adapters/duplicates';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import {
|
||||||
|
setThumbnailSize,
|
||||||
|
THUMBNAIL_SIZE_LABELS,
|
||||||
|
THUMBNAIL_SIZE_PRESETS,
|
||||||
|
view
|
||||||
|
} from '$lib/stores/view.svelte';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||||
|
|
||||||
|
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
|
||||||
|
// toolbar bounces don't refetch the (potentially expensive) stack
|
||||||
|
// listing. Invalidation by mutations is explicit, not time-driven.
|
||||||
|
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||||
|
queryKey: ['duplicates'],
|
||||||
|
queryFn: listDuplicateGroups,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 30_000
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Duplicates · stacks
|
||||||
|
</span>
|
||||||
|
{#snippet trailing()}
|
||||||
|
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
|
||||||
|
store is global, so the picked size persists across routes —
|
||||||
|
when you come back to the timeline it stays where you left it. -->
|
||||||
|
<div
|
||||||
|
class="flex items-center overflow-hidden rounded border border-border"
|
||||||
|
role="group"
|
||||||
|
aria-label="Thumbnail size"
|
||||||
|
>
|
||||||
|
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
||||||
|
class:bg-accent={view.thumbnailSize === size}
|
||||||
|
class:text-foreground={view.thumbnailSize === size}
|
||||||
|
class:text-muted-foreground={view.thumbnailSize !== size}
|
||||||
|
onclick={() => setThumbnailSize(size)}
|
||||||
|
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
||||||
|
>
|
||||||
|
{THUMBNAIL_SIZE_LABELS[i]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{dupesQuery.data?.length ?? 0} group{dupesQuery.data?.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<DuplicatesView
|
||||||
|
groups={dupesQuery.data ?? []}
|
||||||
|
pending={dupesQuery.isPending}
|
||||||
|
error={dupesQuery.error}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
78
web/src/routes/login/+page.svelte
Normal file
78
web/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { login } from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
void goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
toast.success('Signed in');
|
||||||
|
await goto('/', { replaceState: true });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-background p-6">
|
||||||
|
<form
|
||||||
|
onsubmit={onSubmit}
|
||||||
|
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
|
||||||
|
>
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mule</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">Sign in with your PhotoPrism account.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<label class="block space-y-1.5">
|
||||||
|
<span class="text-sm font-medium text-foreground">Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
bind:value={username}
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block space-y-1.5">
|
||||||
|
<span class="text-sm font-medium text-foreground">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
OIDC SSO ships in M4 when the IdP is wired up.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
388
web/src/routes/map/+page.svelte
Normal file
388
web/src/routes/map/+page.svelte
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type MapMouseEvent,
|
||||||
|
type MapSourceDataEvent
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { openPreview } from '$lib/stores/preview.svelte';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
|
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||||
|
queryKey: ['geo'],
|
||||||
|
queryFn: () => listGeo(),
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mapEl: HTMLDivElement | undefined = $state();
|
||||||
|
let map: maplibregl.Map | undefined;
|
||||||
|
/** Reactive flag flipped on once the MapLibre `load` event has fired
|
||||||
|
* and the `photos` source has been installed. The data-push `$effect`
|
||||||
|
* depends on this — otherwise, if the geoQuery resolves before the
|
||||||
|
* basemap style finishes loading, the effect runs with no source
|
||||||
|
* available and never re-runs (since `map` itself is not `$state`),
|
||||||
|
* leaving the map permanently empty. */
|
||||||
|
let mapReady = $state(false);
|
||||||
|
|
||||||
|
/** Markers currently attached to the map, keyed by feature id (UIDs
|
||||||
|
* for photos, `cluster:<clusterId>` for clusters). Diffed against the
|
||||||
|
* current `querySourceFeatures` set on every render to add markers
|
||||||
|
* that came into view and remove ones that scrolled out / got
|
||||||
|
* swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
|
||||||
|
* See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
|
||||||
|
const markers = new Map<string, maplibregl.Marker>();
|
||||||
|
const markersOnScreen = new Map<string, maplibregl.Marker>();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!mapEl) return;
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: mapEl,
|
||||||
|
// PhotoPrism's default basemap style (CDN-hosted, no key required).
|
||||||
|
// The style JSON already references the correct glyphs URL, so
|
||||||
|
// no explicit override is needed here.
|
||||||
|
style: 'https://cdn.photoprism.app/maps/default.json',
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 1,
|
||||||
|
attributionControl: { compact: true }
|
||||||
|
});
|
||||||
|
map.addControl(
|
||||||
|
new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
|
||||||
|
'top-right'
|
||||||
|
);
|
||||||
|
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
addPhotoLayers();
|
||||||
|
mapReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// PhotoPrism's update strategy: re-reconcile markers on every map
|
||||||
|
// movement, on resize (so cluster bubbles re-balance when the
|
||||||
|
// viewport changes), on idle (catches the post-`fitBounds` settle),
|
||||||
|
// and on `sourcedata` filtered to "source fully loaded" — that's
|
||||||
|
// the moment MapLibre has processed clustering and
|
||||||
|
// `querySourceFeatures` returns meaningful results.
|
||||||
|
const onSourceData = (e: MapSourceDataEvent) => {
|
||||||
|
if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
|
||||||
|
};
|
||||||
|
map.on('sourcedata', onSourceData);
|
||||||
|
map.on('move', updateMarkers);
|
||||||
|
map.on('moveend', updateMarkers);
|
||||||
|
map.on('resize', updateMarkers);
|
||||||
|
map.on('idle', updateMarkers);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map?.off('sourcedata', onSourceData);
|
||||||
|
map?.off('move', updateMarkers);
|
||||||
|
map?.off('moveend', updateMarkers);
|
||||||
|
map?.off('resize', updateMarkers);
|
||||||
|
map?.off('idle', updateMarkers);
|
||||||
|
markersOnScreen.forEach((m) => m.remove());
|
||||||
|
markersOnScreen.clear();
|
||||||
|
markers.clear();
|
||||||
|
map?.remove();
|
||||||
|
map = undefined;
|
||||||
|
mapReady = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function addPhotoLayers() {
|
||||||
|
if (!map) return;
|
||||||
|
map.addSource('photos', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] },
|
||||||
|
cluster: true,
|
||||||
|
// PhotoPrism's clustering parameters — points within ~80px merge
|
||||||
|
// below zoom 17, individual photos render above that.
|
||||||
|
clusterMaxZoom: 17,
|
||||||
|
clusterRadius: 80
|
||||||
|
});
|
||||||
|
// Invisible layer for clusters — PhotoPrism does this so the source
|
||||||
|
// reports cluster features via `querySourceFeatures` (which only
|
||||||
|
// returns features actually rendered by some layer) while the
|
||||||
|
// visual presentation is owned by HTML markers below.
|
||||||
|
map.addLayer({
|
||||||
|
id: 'clusters',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'photos',
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
|
||||||
|
});
|
||||||
|
// Click an (invisible) cluster anywhere on the map → zoom to its
|
||||||
|
// expansion level. The marker DOM also has a click handler, but
|
||||||
|
// pointer-through to the map needs this as a fallback.
|
||||||
|
map.on('click', 'clusters', (e: MapMouseEvent) => {
|
||||||
|
const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
|
||||||
|
const clusterId = features[0]?.properties?.cluster_id;
|
||||||
|
if (clusterId == null) return;
|
||||||
|
const source = map!.getSource('photos') as GeoJSONSource;
|
||||||
|
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||||
|
const geometry = features[0]?.geometry;
|
||||||
|
if (!geometry || geometry.type !== 'Point') return;
|
||||||
|
map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cluster bubble diameter, scaled by the number of contained photos
|
||||||
|
* — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
|
||||||
|
function clusterSize(count: number): number {
|
||||||
|
if (count >= 10000) return 74;
|
||||||
|
if (count >= 1000) return 70;
|
||||||
|
if (count >= 750) return 68;
|
||||||
|
if (count >= 200) return 66;
|
||||||
|
if (count >= 100) return 64;
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
|
||||||
|
function abbreviateCount(value: number): string {
|
||||||
|
if (value >= 1000) return `${Math.round(value / 1000)}k`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'marker';
|
||||||
|
if (title) el.title = title;
|
||||||
|
el.style.width = '50px';
|
||||||
|
el.style.height = '50px';
|
||||||
|
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
openPreview(uid, allUids);
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterMarker(clusterId: number, count: number) {
|
||||||
|
const size = clusterSize(count);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'marker';
|
||||||
|
el.style.width = `${size}px`;
|
||||||
|
el.style.height = `${size}px`;
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'cluster-marker';
|
||||||
|
el.appendChild(grid);
|
||||||
|
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'badge';
|
||||||
|
badge.textContent = abbreviateCount(count);
|
||||||
|
el.appendChild(badge);
|
||||||
|
|
||||||
|
// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
|
||||||
|
// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
|
||||||
|
// source is captured once here; `getClusterLeaves` returns a
|
||||||
|
// Promise, so this populates asynchronously and the bubble shows a
|
||||||
|
// dark placeholder until the thumbs arrive.
|
||||||
|
if (map) {
|
||||||
|
const source = map.getSource('photos') as GeoJSONSource | undefined;
|
||||||
|
if (source && typeof source.getClusterLeaves === 'function') {
|
||||||
|
source
|
||||||
|
.getClusterLeaves(clusterId, 4, 0)
|
||||||
|
.then((leaves) => {
|
||||||
|
const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
|
||||||
|
grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
|
||||||
|
for (let i = 0; i < previewCount; i++) {
|
||||||
|
const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
|
||||||
|
const props = (leaf?.properties ?? {}) as { Hash?: string };
|
||||||
|
if (!props.Hash) continue;
|
||||||
|
const tile = document.createElement('div');
|
||||||
|
tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
|
||||||
|
grid.appendChild(tile);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (!map) return;
|
||||||
|
const source = map.getSource('photos') as GeoJSONSource;
|
||||||
|
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||||
|
// Use the marker's current LngLat — set just below in updateMarkers.
|
||||||
|
const m = markers.get(`cluster:${clusterId}`);
|
||||||
|
const ll = m?.getLngLat();
|
||||||
|
if (!ll) return;
|
||||||
|
map!.easeTo({ center: ll, zoom });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile HTML markers against what's currently in the rendered
|
||||||
|
* source. PhotoPrism's `updateMarkers`. */
|
||||||
|
function updateMarkers() {
|
||||||
|
if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
|
||||||
|
const features = map.querySourceFeatures('photos');
|
||||||
|
const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const f of features) {
|
||||||
|
const props = (f.properties ?? {}) as Record<string, unknown> & {
|
||||||
|
cluster?: boolean;
|
||||||
|
cluster_id?: number;
|
||||||
|
point_count?: number;
|
||||||
|
UID?: string;
|
||||||
|
Hash?: string;
|
||||||
|
Title?: string;
|
||||||
|
};
|
||||||
|
const geom = f.geometry;
|
||||||
|
if (geom.type !== 'Point') continue;
|
||||||
|
const coords = geom.coordinates as [number, number];
|
||||||
|
|
||||||
|
let key: string;
|
||||||
|
let buildEl: () => HTMLElement;
|
||||||
|
if (props.cluster) {
|
||||||
|
if (props.cluster_id == null) continue;
|
||||||
|
key = `cluster:${props.cluster_id}`;
|
||||||
|
const cid = props.cluster_id;
|
||||||
|
const count = props.point_count ?? 0;
|
||||||
|
buildEl = () => buildClusterMarker(cid, count);
|
||||||
|
} else {
|
||||||
|
if (!props.UID || !props.Hash) continue;
|
||||||
|
key = props.UID;
|
||||||
|
const uid = props.UID;
|
||||||
|
const hash = props.Hash;
|
||||||
|
const title = props.Title;
|
||||||
|
buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
let marker = markers.get(key);
|
||||||
|
if (!marker) {
|
||||||
|
marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
|
||||||
|
markers.set(key, marker);
|
||||||
|
} else {
|
||||||
|
marker.setLngLat(coords);
|
||||||
|
}
|
||||||
|
if (!markersOnScreen.has(key)) {
|
||||||
|
marker.addTo(map);
|
||||||
|
markersOnScreen.set(key, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, marker] of markersOnScreen) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
marker.remove();
|
||||||
|
markersOnScreen.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push new geo data into the source whenever the query resolves AND
|
||||||
|
// the map is ready. Both orderings are handled: if data arrives first,
|
||||||
|
// the effect re-runs when `mapReady` flips; if the map is ready first,
|
||||||
|
// it re-runs when `data` arrives.
|
||||||
|
$effect(() => {
|
||||||
|
const data = geoQuery.data as
|
||||||
|
| (PpGeoCollection & { bbox?: number[] })
|
||||||
|
| undefined;
|
||||||
|
if (!map || !mapReady || !data) return;
|
||||||
|
const src = map.getSource('photos') as GeoJSONSource | undefined;
|
||||||
|
if (!src) return;
|
||||||
|
src.setData(data as GeoJSON.FeatureCollection);
|
||||||
|
|
||||||
|
// Drop stale markers; updateMarkers will rebuild for the current
|
||||||
|
// visible set on the next `sourcedata` (fired by setData) or `idle`.
|
||||||
|
markersOnScreen.forEach((m) => m.remove());
|
||||||
|
markersOnScreen.clear();
|
||||||
|
markers.clear();
|
||||||
|
|
||||||
|
// Fit to data extent on the first non-empty load — prefer the
|
||||||
|
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||||
|
// the features.
|
||||||
|
if ((data.features?.length ?? 0) > 0) {
|
||||||
|
let bounds: maplibregl.LngLatBoundsLike | null = null;
|
||||||
|
if (Array.isArray(data.bbox) && data.bbox.length === 4) {
|
||||||
|
bounds = [
|
||||||
|
[data.bbox[0], data.bbox[1]],
|
||||||
|
[data.bbox[2], data.bbox[3]]
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const b = new maplibregl.LngLatBounds();
|
||||||
|
for (const f of data.features as PpGeoFeature[]) {
|
||||||
|
const c = f.geometry.coordinates as [number, number];
|
||||||
|
if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
|
||||||
|
}
|
||||||
|
if (!b.isEmpty()) bounds = b;
|
||||||
|
}
|
||||||
|
if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Map
|
||||||
|
</span>
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{geoQuery.data?.features?.length ?? 0} geotagged
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* PhotoPrism's marker / cluster styling, ported from
|
||||||
|
frontend/src/css/places.css. `:global` because MapLibre appends
|
||||||
|
markers outside Svelte's scoped CSS reach. */
|
||||||
|
:global(.maplibregl-map .marker) {
|
||||||
|
display: block;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #ffffff99;
|
||||||
|
background-color: rgba(23, 23, 23, 0.23);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
box-shadow:
|
||||||
|
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||||
|
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||||
|
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
:global(.maplibregl-map .cluster-marker) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
:global(.maplibregl-map .cluster-marker > div) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
:global(.maplibregl-map .badge) {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #53478a;
|
||||||
|
box-shadow:
|
||||||
|
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||||
|
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||||
|
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
web/src/routes/photo/[uid]/+page.svelte
Normal file
16
web/src/routes/photo/[uid]/+page.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { openPreview } from '$lib/stores/preview.svelte';
|
||||||
|
|
||||||
|
// Deep-link entry: opening /photo/<uid> directly pops the overlay on
|
||||||
|
// the timeline. The route itself does not render anything; it hands
|
||||||
|
// off to the global PreviewOverlay and redirects to `/` so the URL
|
||||||
|
// stays clean and the timeline shows behind the modal.
|
||||||
|
$effect(() => {
|
||||||
|
const uid = page.params.uid as string | undefined;
|
||||||
|
if (!uid) return;
|
||||||
|
openPreview(uid);
|
||||||
|
void goto('/', { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
171
web/src/routes/ratings/+page.svelte
Normal file
171
web/src/routes/ratings/+page.svelte
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
getAllMarks,
|
||||||
|
listPhotos,
|
||||||
|
type PhotoMarksMap
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { openPreview } from '$lib/stores/preview.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
|
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
||||||
|
// they live in mule-sidecar's marks map. We fan in two queries: marks
|
||||||
|
// (UID → {rating, color}) and a recent slice of photos (UID → photo)
|
||||||
|
// so we can resolve the thumbnail hash for each rated UID.
|
||||||
|
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||||
|
queryKey: ['marks'],
|
||||||
|
queryFn: getAllMarks,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photosQuery = createQuery<PpPhoto[]>(() => ({
|
||||||
|
queryKey: ['photos', 'ratings-pool'],
|
||||||
|
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface RatingGroup {
|
||||||
|
rating: number;
|
||||||
|
photos: PpPhoto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = $derived<RatingGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
|
||||||
|
|
||||||
|
function buildGroups(
|
||||||
|
marks: PhotoMarksMap | undefined,
|
||||||
|
pool: PpPhoto[] | undefined
|
||||||
|
): RatingGroup[] {
|
||||||
|
if (!marks || !pool) return [];
|
||||||
|
const byUid = new Map(pool.map((p) => [p.UID, p]));
|
||||||
|
const buckets = new Map<number, PpPhoto[]>();
|
||||||
|
for (const [uid, mark] of Object.entries(marks)) {
|
||||||
|
const r = mark.rating ?? 0;
|
||||||
|
if (r <= 0) continue;
|
||||||
|
const photo = byUid.get(uid);
|
||||||
|
if (!photo) continue;
|
||||||
|
const arr = buckets.get(r) ?? [];
|
||||||
|
arr.push(photo);
|
||||||
|
buckets.set(r, arr);
|
||||||
|
}
|
||||||
|
const out: RatingGroup[] = [];
|
||||||
|
for (let r = 5; r >= 1; r--) {
|
||||||
|
const photos = buckets.get(r);
|
||||||
|
if (photos && photos.length > 0) out.push({ rating: r, photos });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = $state<number | null>(null);
|
||||||
|
const selectedGroup = $derived(
|
||||||
|
selected !== null ? groups.find((g) => g.rating === selected) ?? null : null
|
||||||
|
);
|
||||||
|
|
||||||
|
function pickGroup(rating: number) {
|
||||||
|
selected = rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function starLabel(rating: number): string {
|
||||||
|
return '★'.repeat(rating);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Ratings
|
||||||
|
</span>
|
||||||
|
{#if selectedGroup}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||||
|
onclick={clearSelection}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span class="text-[11px] font-medium text-yellow-500">
|
||||||
|
{starLabel(selectedGroup.rating)}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{groups.length} rating{groups.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
{#if marksQuery.isPending || photosQuery.isPending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||||
|
{:else if marksQuery.isError || photosQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">Failed to load ratings.</p>
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
No rated photos yet. Open a photo and use the star row in the right sidebar
|
||||||
|
(or 1–5 in bulk mode) to rate it.
|
||||||
|
</p>
|
||||||
|
{:else if selectedGroup}
|
||||||
|
<div
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each selectedGroup.photos as photo (photo.UID)}
|
||||||
|
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() =>
|
||||||
|
openPreview(
|
||||||
|
photo.UID,
|
||||||
|
selectedGroup.photos.map((p) => p.UID)
|
||||||
|
)}
|
||||||
|
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(hash, 'tile_500')}
|
||||||
|
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid gap-3"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each groups as group (group.rating)}
|
||||||
|
{@const rep = group.photos[0]}
|
||||||
|
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||||
|
onclick={() => pickGroup(group.rating)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(hash, 'tile_500')}
|
||||||
|
alt={starLabel(group.rating)}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span class="truncate font-medium text-yellow-500">
|
||||||
|
{starLabel(group.rating)}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
68
web/src/routes/tags/+page.svelte
Normal file
68
web/src/routes/tags/+page.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { listLabels, type PpLabel } from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
|
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||||
|
queryKey: ['labels'],
|
||||||
|
queryFn: listLabels,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function openLabel(slug: string) {
|
||||||
|
// Tags drive search — clicking jumps to the timeline with the label
|
||||||
|
// term applied. Bookmarkable URL via the existing filter sync.
|
||||||
|
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Tags
|
||||||
|
</span>
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
{#if labelsQuery.isPending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||||
|
{:else if labelsQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">Failed to load labels.</p>
|
||||||
|
{:else if (labelsQuery.data ?? []).length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
||||||
|
indexer hasn't run on real photos yet, the list will be empty.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
|
||||||
|
{#each labelsQuery.data ?? [] as label (label.UID)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||||
|
onclick={() => openLabel(label.CustomSlug ?? label.Slug)}
|
||||||
|
>
|
||||||
|
{#if label.Thumb}
|
||||||
|
<img
|
||||||
|
src={thumbUrl(label.Thumb, 'tile_500')}
|
||||||
|
alt={label.Name}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span class="truncate font-medium">{label.Name}</span>
|
||||||
|
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
BIN
web/static/favicon.png
Normal file
BIN
web/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 821 KiB |
BIN
web/static/mule/desert.png
Normal file
BIN
web/static/mule/desert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 646 KiB |
BIN
web/static/mule/mule-sprites.png
Normal file
BIN
web/static/mule/mule-sprites.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
3
web/static/robots.txt
Normal file
3
web/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
18
web/svelte.config.js
Normal file
18
web/svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries.
|
||||||
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
|
},
|
||||||
|
kit: {
|
||||||
|
// SPA mode: every unknown path serves index.html, which then mounts
|
||||||
|
// the client router. Required for dynamic routes (e.g. /photo/[uid]).
|
||||||
|
adapter: adapter({
|
||||||
|
fallback: 'index.html'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
25
web/vite.config.ts
Normal file
25
web/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// mule-sidecar — file rename + (future) folder mutations and
|
||||||
|
// per-user heap sharing. Matched BEFORE `/api/*` so PhotoPrism
|
||||||
|
// doesn't swallow these paths.
|
||||||
|
'/api/sidecar': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: false,
|
||||||
|
rewrite: (p) => p
|
||||||
|
},
|
||||||
|
// PhotoPrism REST + WebSocket.
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:2342',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user