3 Commits

Author SHA1 Message Date
0766b47bb2 feat(sidebar): library + general settings dialogs, sidebar footer, alignment polish
- Add a Settings cog to the Folders header that opens a tabbed library
  admin dialog (Library / Index / Import / Logs) wrapping PhotoPrism's
  /api/v1 settings, index, import and errors endpoints.
- Add a sticky footer to the left sidebar with the signed-in user's
  display name plus quick-toggle theme, general-settings cog (separate
  dialog for app prefs), and sign-out. Pull these out of the top
  Toolbar trailing slot.
- Align depth-0 folder rows with the rest of the sidebar entries (drop
  the leading chevron column when no children) and bring heap rows in
  line with folder rows so the kebab is part of the row's hover
  background instead of a detached chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:51:56 +02:00
17df1ecd09 feat(preview): play videos inline in the lightbox overlay
Switch the preview overlay from a still <img> to a <video> tag when the
focused photo's Type is "video". Uses PhotoPrism's /api/v1/videos/:hash
endpoint with the existing previewToken, falls back to a still thumb as
the poster, and autoplays muted so the controls reveal without
clobbering whatever else is on the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:51:46 +02:00
8c2526d982 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>
2026-05-17 16:06:58 +02:00
71 changed files with 12771 additions and 0 deletions

69
.env.photoprism.example Normal file
View 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 M0M3. 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
View File

@@ -37,6 +37,7 @@ dist-ssr/
.env
.env.local
.env.*.local
.env.photoprism
# Database
*.db
@@ -61,6 +62,16 @@ build/
# Docker
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/

View 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

View File

@@ -0,0 +1,127 @@
# PhotoPrism stack — replaces the legacy mule-image backend over the course of
# milestones M0M5 (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:

View 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
View 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`).

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

42
web/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View 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
View 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
View 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
View 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>

View 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 + (19) 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 19 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 19 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 19 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 19 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();
}
};
}

View 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();
}
};
}

View 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);
}
};
}

View 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;
}

View 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

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,194 @@
<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). 8px baseline aligns the depth-0 chevron
with the px-2 of Views/Heaps rows; +12px per nested 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: {8 + 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 if depth > 0}
<!-- Spacer keeps childless siblings aligned with their chevroned
peers at nested depths. Skipped at depth 0 so root folders
left-align with the Views/Heaps rows. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<button
class="flex flex-1 items-center truncate text-left"
class:px-1={hasChildren || depth > 0}
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>

View File

@@ -0,0 +1,83 @@
<!--
General app preferences. Distinct from the PhotoPrism-library admin dialog:
this one owns settings that affect *this* SvelteKit shell (theme), not the
server. Opened from the bottom of the left sidebar.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { mode, setMode } from 'mode-watcher';
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'system', label: 'System', Icon: Monitor }
] as const;
</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-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app. Library-side settings live under
Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</Dialog.Close>
</div>
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Appearance
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{/each}
</div>
</section>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View 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>

View 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>

View File

@@ -0,0 +1,479 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { mode, toggleMode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import {
createFolder,
createHeap,
deleteFolder,
deleteHeap,
duplicateHeap,
heapDownloadUrl,
listFolders,
listHeaps,
logout,
renameFolder,
renameHeap,
triggerDownload,
type PpAlbum,
type PpFolder
} from '$lib/services/photoprism';
import {
filters,
setFolderPath,
setSection,
type Section
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import {
Copy,
Download,
FolderInput,
LogOut,
Moon,
Pencil,
Settings,
Sun,
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);
// Library/admin settings dialog visibility.
let settingsOpen = $state(false);
// App-wide preferences dialog (theme etc.). Distinct from the library
// admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false);
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
}
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>
<div class="flex h-full flex-col">
<nav class="flex-1 space-y-3 overflow-y-auto p-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 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}
>
<button
class="flex flex-1 items-center gap-2 px-2 text-left"
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="mr-1">
<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 gap-0.5 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-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
title="Library settings"
aria-label="Library settings"
>
<Settings class="h-3 w-3" />
</button>
<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>
<!--
Footer — fixed to the bottom of the sidebar. Holds the per-user
affordances (display name, quick theme toggle, general preferences,
sign-out) that used to live in the top toolbar.
-->
<footer
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
>
<span
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
title={session.user?.DisplayName ?? session.user?.Name ?? ''}
>
{session.user?.DisplayName ?? session.user?.Name ?? 'Signed in'}
</span>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={toggleMode}
title="Toggle theme"
aria-label="Toggle theme"
>
{#if mode.current === 'dark'}
<Sun class="h-3.5 w-3.5" />
{:else}
<Moon class="h-3.5 w-3.5" />
{/if}
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (generalSettingsOpen = true)}
title="General settings"
aria-label="General settings"
>
<Settings class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={onSignOut}
title="Sign out"
aria-label="Sign out"
>
<LogOut class="h-3.5 w-3.5" />
</button>
</footer>
</div>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
<GeneralSettingsDialog
open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)}
/>

View File

@@ -0,0 +1,439 @@
<!--
Library admin dialog. Tabs map 1-to-1 to PhotoPrism's own Library page:
general settings, manual index, manual import, server error log.
Each tab owns its own query/mutation pair via TanStack Query so the data
loads on first open and the rest of the app can read the same caches.
-->
<script lang="ts">
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import {
cancelImport,
cancelIndex,
getErrors,
getSettings,
saveSettings,
startImport,
startIndex,
type ImportBody,
type IndexBody,
type PpLogEntry,
type PpSettings
} from '$lib/services/photoprism';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library');
// ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them
// warm in the background. Edits work on a local clone; Save POSTs it
// back wholesale (PhotoPrism deep-merges server-side).
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
}
});
// Reset the draft when the dialog closes so the next open re-reads.
$effect(() => {
if (!open) draft = null;
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = structuredClone(next);
toast.success('Settings saved');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save settings')
}));
function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
}
// ── Index tab ─────────────────────────────────────────────────────────
let indexForm = $state<IndexBody>({ path: '/', rescan: false, cleanup: false });
const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Index failed')
}));
const cancelIndexMut = createMutation(() => ({
mutationFn: () => cancelIndex(),
onSuccess: () => toast.success('Indexing canceled'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
// ── Import tab ────────────────────────────────────────────────────────
let importForm = $state<ImportBody>({ path: '/', move: false, dest: '' });
const startImportMut = createMutation(() => ({
mutationFn: (b: ImportBody) => startImport(b),
onSuccess: (r) => toast.success(r.message || 'Import complete'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Import failed')
}));
const cancelImportMut = createMutation(() => ({
mutationFn: () => cancelImport(),
onSuccess: () => toast.success('Import canceled'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
// ── Logs tab ──────────────────────────────────────────────────────────
// Poll while the Logs tab is showing; pause otherwise so the dialog
// doesn't burn requests when the user is in another tab.
const errorsQuery = createQuery<PpLogEntry[]>(() => ({
queryKey: ['errors'],
queryFn: () => getErrors({ limit: 200 }),
enabled: open && activeTab === 'logs',
refetchInterval: open && activeTab === 'logs' ? 5000 : false
}));
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<Settings class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
Library settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Drive PhotoPrism's library, indexer, importer and server log.
</Dialog.Description>
</div>
<Dialog.Close
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</Dialog.Close>
</div>
<Tabs.Root bind:value={activeTab}>
<Tabs.List
class="mb-3 flex gap-1 border-b border-border"
>
{#each ['library', 'index', 'import', 'logs'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
>
{t}
</Tabs.Trigger>
{/each}
</Tabs.List>
<!-- Library — general settings -->
<Tabs.Content value="library" class="outline-none">
{#if settingsQuery.isPending}
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
{:else if draft}
<div class="max-h-[55vh] space-y-4 overflow-y-auto pr-1 text-[12px]">
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Indexer defaults
</h3>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.convert}
/>
Convert RAW / HEIC on index
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.rescan}
/>
Rescan known files
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipArchived}
/>
Skip archived photos
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Importer defaults
</h3>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.import!.move} />
Move (instead of copy) on import
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Default destination subpath</span>
<input
type="text"
placeholder="e.g. 2026/05"
bind:value={draft.import!.dest}
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Stacks
</h3>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.stack!.uuid} />
Stack files sharing a UUID
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.stack!.meta} />
Stack files with matching metadata
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.stack!.name} />
Stack files with matching names
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Downloads
</h3>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.originals}
/>
Originals
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.mediaRaw}
/>
Include RAW
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.mediaSidecar}
/>
Include sidecar files
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.disabled}
/>
Disable downloads entirely
</label>
</section>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={resetDraft}
disabled={saveMut.isPending}
>
Revert
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => draft && saveMut.mutate(draft)}
disabled={saveMut.isPending}
>
{#if saveMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Save
</button>
</div>
{/if}
</Tabs.Content>
<!-- Index — manual indexer run -->
<Tabs.Content value="index" class="space-y-3 text-[12px] outline-none">
<p class="text-muted-foreground">
Rebuilds the search index by walking the originals folder. Safe to
run while users are connected.
</p>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Path</span>
<input
type="text"
bind:value={indexForm.path}
placeholder="/"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={indexForm.rescan} />
Rescan files already in the index
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={indexForm.cleanup} />
Clean up missing files
</label>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
onclick={() => cancelIndexMut.mutate()}
disabled={cancelIndexMut.isPending || startIndexMut.isPending}
>
Cancel current
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => startIndexMut.mutate(indexForm)}
disabled={startIndexMut.isPending}
>
{#if startIndexMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Start indexing
</button>
</div>
</Tabs.Content>
<!-- Import — manual import run -->
<Tabs.Content value="import" class="space-y-3 text-[12px] outline-none">
<p class="text-muted-foreground">
Pulls files from the import folder into the library. With "move"
enabled, files are deleted from the import folder after a
successful import.
</p>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Source path</span>
<input
type="text"
bind:value={importForm.path}
placeholder="/"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={importForm.move} />
Move files (don't copy) after import
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Destination subpath (optional)</span>
<input
type="text"
bind:value={importForm.dest}
placeholder="e.g. 2026/05"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
onclick={() => cancelImportMut.mutate()}
disabled={cancelImportMut.isPending || startImportMut.isPending}
>
Cancel current
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => startImportMut.mutate(importForm)}
disabled={startImportMut.isPending}
>
{#if startImportMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Start import
</button>
</div>
</Tabs.Content>
<!-- Logs — recent server errors -->
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
<div class="flex items-center justify-between">
<p class="text-muted-foreground">
Most recent PhotoPrism errors and warnings. Auto-refreshes
every 5 seconds.
</p>
<button
type="button"
class="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
onclick={() => qc.invalidateQueries({ queryKey: ['errors'] })}
disabled={errorsQuery.isFetching}
>
<RefreshCw
class="h-3 w-3 {errorsQuery.isFetching ? 'animate-spin' : ''}"
/>
Refresh
</button>
</div>
{#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p>
{:else if errorsQuery.isError}
<p class="px-1 text-destructive">Could not load error log.</p>
{:else if (errorsQuery.data ?? []).length === 0}
<p class="px-1 text-muted-foreground">No errors logged.</p>
{:else}
<ul
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"
>
{#each errorsQuery.data ?? [] as entry, i (i)}
<li class="leading-snug">
<span class="text-muted-foreground">{entry.Time}</span>
<span
class:text-destructive={entry.Level === 'error'}
class:text-yellow-500={entry.Level === 'warn' ||
entry.Level === 'warning'}
>
[{entry.Level}]
</span>
{entry.Message}
</li>
{/each}
</ul>
{/if}
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View 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>

View 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>

View File

@@ -0,0 +1,176 @@
<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, videoUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import { isVideo, primaryFile, videoFile, 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)}
{#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={videoUrl(vf.Hash)}
poster={thumbUrl(pf.Hash, 'fit_1920')}
controls
autoplay
muted
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
></video>
{:else}
<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}
{/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}

View 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>

View 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>

View 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 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>S&nbsp;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
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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
}
}
});

View 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 };
});
}

View 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 };
}

View 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);
});
}
}

View File

@@ -0,0 +1,693 @@
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;
}
// ── Settings / Admin ─────────────────────────────────────────────────────────
//
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint
// merges server-side, so it's safe to round-trip an incomplete object.
export interface PpSettings {
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
name?: string;
disabled?: boolean;
originals?: boolean;
mediaRaw?: boolean;
mediaSidecar?: boolean;
};
[k: string]: unknown;
}
export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings');
return data;
}
export async function saveSettings(patch: Partial<PpSettings>): Promise<PpSettings> {
const { data } = await http.post<PpSettings>('/settings', patch);
return data;
}
export interface IndexBody {
path?: string;
rescan?: boolean;
cleanup?: boolean;
}
export async function startIndex(body: IndexBody = {}): Promise<{ message: string }> {
const { data } = await http.post<{ message: string }>('/index', {
path: '/',
rescan: false,
cleanup: false,
...body
});
return data;
}
export async function cancelIndex(): Promise<void> {
await http.delete('/index');
}
export interface ImportBody {
path?: string;
move?: boolean;
dest?: string;
}
export async function startImport(body: ImportBody = {}): Promise<{ message: string }> {
const { data } = await http.post<{ message: string }>('/import', {
path: '/',
move: false,
dest: '',
...body
});
return data;
}
export async function cancelImport(): Promise<void> {
await http.delete('/import');
}
export interface PpLogEntry {
Time: string;
Level: string;
Message: string;
}
export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEntry[]> {
const { data } = await http.get<PpLogEntry[]>('/errors', {
params: { limit: opts.limit ?? 200 }
});
return data ?? [];
}
// ── Re-exports ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };

View 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;
}

View 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];
}

View 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;
}

View File

@@ -0,0 +1,100 @@
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}`;
}
/**
* Build a video stream URL. PhotoPrism's endpoint is
* /api/v1/videos/:hash/:token/:format — same previewToken as thumbnails.
* `format=avc` is the standard h264 transcode; HEVC sources are
* transcoded on first request and cached server-side.
*/
export function videoUrl(hash: string, format = 'avc'): string {
if (!session.previewToken) return '';
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`;
}

View 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;
}

View 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();
}

View File

@@ -0,0 +1,216 @@
/**
* 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 function isVideo(p: PpPhoto): boolean {
return p.Type === 'video';
}
/** Return the Files[] entry that carries the actual video stream. Falls back
* to primaryFile() if no video MediaType is present (shouldn't happen for
* Type === 'video' but keeps the call site total). */
export function videoFile(p: PpPhoto): PpFile {
const files = p.Files ?? [];
const v = files.find((f) => f.MediaType?.startsWith('video/'));
return v ?? primaryFile(p);
}
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
View 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));
}

View File

@@ -0,0 +1,85 @@
<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;"
>
<!-- LeftSidebar owns its own flex-col layout so its footer
row (user/theme/settings/logout) can pin to the bottom
while the nav above scrolls. -->
<LeftSidebar />
<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>

View 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;

854
web/src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,854 @@
<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 { toast } from 'svelte-sonner';
import {
batchDelete,
getPhoto,
listHeaps,
listPhotos,
type PpAlbum
} from '$lib/services/photoprism';
import {
filters,
filtersToQ,
filtersToUrlParams,
parseUrlParams,
setSearch,
setSection
} from '$lib/stores/filters.svelte';
import { isAuthenticated, 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 { isVideo, 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
}));
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>
{/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}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/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 />

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 15 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>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

BIN
web/static/mule/desert.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

3
web/static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

18
web/svelte.config.js Normal file
View 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
View 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
View 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
}
}
}
});