34 Commits

Author SHA1 Message Date
3e164c48d0 fix(notes): page PhotoPrism server-side so /notes shows every captioned photo
Client-side paging of listPhotosWithNotes stopped early for BasePath users:
the sidecar post-filters each page by BasePath, so a full upstream page can
arrive short, tripping the `length < PAGE` end condition before the library
is exhausted — hiding notes past the first slice.

Add GET /api/sidecar/notes: the sidecar pages /api/v1/photos to completion
(keying the loop off the raw upstream page length), filters to non-empty
Caption under the caller's BasePath, dedupes by UID, and returns the set.
listPhotosWithNotes now calls this single endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:43:56 +02:00
259adb6a41 fix: tile overlay reactivity + facets showing all marked/noted photos
Problem 1 — per-photo progress overlay never rendered on the grid:
- bulkPhotoStates was `$state(new Map())`; a `.get(uid)` read in PhotoTile
  didn't reliably re-run when the entry flipped, so the spinner/check/X
  overlay never appeared. Switch to SvelteMap (svelte/reactivity).

Problem 2 — Notes / Colors / Ratings only showed the newest ~1000 photos:
- All three derived from `listPhotos({ count: 1000 })`, silently hiding
  older marked/noted photos.
- listPhotosWithNotes now pages the whole library.
- Add listPhotosByUids() and resolve the Colors/Ratings marks-pool from the
  complete marked-UID set (from getAllMarks) instead of the newest slice;
  wire it into the TagsBrowserSidebar panel and the tag drill page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:37:30 +02:00
ccf2c6b7c7 fix: bulk label apply, instant archive removal, per-photo state, drop sidebar counts
Issue 1 — colors/labels not applying in bulk:
- sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/
  purple/pink returned "invalid color" and rolled back the whole bulk txn.
  Add teal, blue, purple, pink to validColors.
- Add invalidateFacets() and call it on the success path of bulk marks,
  patchTargets, and single-photo edits so the Colors/Ratings/Notes facet
  sections refresh immediately instead of waiting out staleTime.

Issue 2 — archived photos linger in the grid:
- Add a UI-only removedIds set to the bulkAction store; archive/delete/
  restore/keep call markRemoved() on success so tiles vanish instantly,
  cleared once the server-reconcile refetch lands (no cache eviction).

Issue 3 — per-photo progress state:
- Wire startBulk/doneBulk/failBulk into all metadata applies, bulk
  (BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/
  notes/dates/keywords show the spinner -> check -> X overlay.

Issue 4 — remove Left-sidebar count badges:
- Drop count badges from root folder, Archive, heaps, Notes, and the
  folder tree, plus the now-dead count queries and unused imports. Facet
  drill-panel counts are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:21:04 +02:00
a13e171295 bump post-action delay to 1s for PhotoPrism indexer to catch up
200ms was too short - PhotoPrism's batch archive marks photos in the DB
but the search index (used by /api/v1/photos) updates asynchronously.
1s gives the indexer time to reflect the change before we invalidate
and refetch the timeline.
2026-06-07 22:43:24 +02:00
73c36b4817 revert evictFromCache, add back delay + marks invalidation
Remove evictFromCache entirely - the cache-manipulation approach was
brittle and broke both archive (photos not removed) and tags
(colors/ratings showing empty). Replace with: 200ms delay before
invalidation to let PhotoPrism's indexer process the change, plus
invalidateQueries(['marks']) so tag caches (colors, ratings, notes)
refresh alongside the photo timeline.
2026-06-07 22:31:39 +02:00
82f2a40269 fix: folder loading gets error state + gcTime=0 for fresh refetch
Add a proper error branch to the folder tree so a failed sidecar request
shows an error state rather than a perpetual loading spinner. Also sets
gcTime: 0 so the query re-fetches fresh data when the sidebar remounts
instead of holding onto stale cache across navigations.
2026-06-07 22:10:44 +02:00
f6c0f7a507 fix: evictFromCache only targets infinite queries (not flat caches)
evictFromCache was removing archived UIDs from ALL ['photos']-prefixed
queries, including flat lists like marks-pool and with-notes. This
corrupted the tag drill pages — when navigating to Colors/Ratings after
setting marks, the pool was missing photos and the grid showed empty.
Now only infinite queries (those with a pages array) are filtered.
2026-06-07 22:05:41 +02:00
1df16a6142 perf: evict archived/deleted photos from cache immediately
Remove archived/restored/deleted/approved UIDs from all cached photo-list
pages right after the API confirms, so the grid updates on the same tick
instead of waiting for a network round-trip. Also removes the 400ms
doneBulk animation delay (now unnecessary since tiles vanish instantly).
2026-06-07 21:53:16 +02:00
5da1022ed1 feat: loading toasts for all photo actions
Add loading→success/error toast transition to every bulk operation
(archive, restore, delete, approve, add-to-heap, metadata patch).
Also wires gridKeyNav + CauseGroupCard into the bulkAction store so
keyboard-triggered actions show the same per-tile pending/done/error
feedback as BulkActionBar buttons.
2026-06-07 21:40:18 +02:00
dtoro
da63ad769a feat: bulk action progress — header pill + per-thumbnail states
- New bulkAction store: tracks active/label/detail state for the pill
  and a Map<uid, pending|done|error> for per-tile overlays
- Extract StatusPill.svelte from IndexerStatusPill (generic active/label/detail
  props); IndexerStatusPill becomes a one-line wrapper
- +layout.svelte: render a second StatusPill driven by bulkAction store,
  alongside the indexer pill in the AnimatedMule header
- BulkActionBar: extend withBusy with optional BulkConfig (ids/label/doneLabel);
  pending tiles dim + spinner on start, green checkmark flashes for 400ms before
  cache invalidation removes them; red overlay on error, auto-clears after 2s
- onApprove/batchEdit: wire onProgress callback to setDetail so the pill shows
  the filename currently being processed during fan-out keep operations
- batch.ts: add completedId as third arg to onProgress (backwards-compatible)
- PhotoTile: derive bulkState from store; pending/done/error overlays sit above
  the selection tint; hover-video guarded against pending tiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:43:12 +02:00
dtoro
86e38e152d perf: batch label stats query + fix scoped thumb 2026-06-07 00:33:23 +02:00
dtoro
3757eb0170 fix: use /usr/bin/mariadb-admin for mariadb healthcheck
MariaDB 11 renamed mysqladmin → mariadb-admin; neither the old
healthcheck.sh probe nor mysqladmin is reachable from the Docker
healthcheck exec context. Switch to the full-path binary that is
confirmed present in the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:23:28 +02:00
dtoro
cfd0c6aa81 fix: rename sidecar() helper → callSidecar() and fix mariadb healthcheck
The new `const sidecar` axios instance (added in 243e5d3) clashed with the
pre-existing `async function sidecar()` fetch helper, causing `npm run build`
to fail with a rolldown redeclaration error. Rename the fetch helper and all
its call sites to `callSidecar`.

Also replace the mariadb healthcheck command: `healthcheck.sh` calls the
`mariadb` CLI which isn't on PATH in the current image layer, causing the
container to stay permanently unhealthy and the deploy webhook to abort before
the `npm run build` step runs — leaving the old JS bundle serving from nginx.
Switch to `mysqladmin ping` which is available in all MariaDB 11 images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:13:01 +02:00
243e5d3831 fix: sidecar response interceptor + folder proxy
- Added 401-handling response interceptor to sidecar axios instance
  (matches existing http instance) so expired/invalid tokens redirect
  to login instead of showing raw 404/401 errors
- Added GET /api/sidecar/folders — proxies PhotoPrism's
  /api/v1/folders/originals with BasePath post-filter
- Updated listFolders() frontend to call sidecar proxy
- Updated plan with remaining fixes
2026-06-06 23:01:38 +02:00
14a1b4e54e frontend: route all photo queries through sidecar timeline proxy
- listPhotos → /api/sidecar/timeline (was /photos)
- countPhotos → /api/sidecar/timeline (was /photos)
- hasPhotosMatching → /api/sidecar/timeline (was /photos)
- Sidecar handler forwards X-Count header for countPhotos()
- Sidecar adjusts X-Count to reflect post-filtered count
2026-06-06 20:17:13 +02:00
7df1c04c0f sidecar: scoped photos/timeline proxy (fixes review + archive leak)
- New GET /api/sidecar/timeline — proxies PP's /api/v1/photos and
  post-filters by FileName prefix matching the user's BasePath
- Also works for review/archive views (q=review:true, q=archived:true)
- Frontend route uses /timeline to avoid Gin route conflict with
  existing /photos/:uid/marks pattern
2026-06-06 20:11:07 +02:00
8f97590d9f sidecar: scoped labels + counts proxy (fixes cross-user label leak)
- New GET /api/sidecar/labels — proxies PP's labels, recalculates
  PhotoCount per user's BasePath via DB query
- New GET /api/sidecar/counts — returns user-scoped sidebar badges
  (all, review, archived, private, photos, videos, favorites)
- Fixed auth middleware to expose userUID and basePath on context
- Fixed ppClient.resolveSession — uses correct endpoint
  (GET /api/v1/session, not /api/v1/session/{token}) and correct
  JSON field names (UID, Name instead of UserUID, UserName)
- Frontend: listLabels now calls /api/sidecar/labels instead of /api/v1/labels
2026-06-06 19:23:22 +02:00
4c08eba27a fix: scope marks, labels, and subjects to the authenticated user
Marks (ratings/color labels) were stored without a user column — every
user saw every other user's marks. Labels and subjects from PhotoPrism's
global endpoints leaked across users because those endpoints ignore
BasePath ACL.

Sidecar:
- Add UserName as composite primary key on Mark (photo_uid, user_name)
- Replace validateSession with resolveSession that fetches the user
  identity from PhotoPrism's session endpoint
- Filter all mark queries by user_name

Frontend:
- Filter listLabels/listSubjects through a BasePath-aware existence
  check — each label/subject is kept only if the user has at least one
  matching photo (single count=1 probe per item, batched at concurrency 8)
- Skip filtering for admin users with empty BasePath (single-user compat)

Also documents USER_BASEPATHS in .env.example — the env var that drives
per-user library isolation via PhotoPrism's auth_users.base_path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 12:36:18 +02:00
6c96c22b33 feat(compose): opt-in GPU overlay for VA-API ffmpeg accel
Layer docker-compose.gpu.yml to mount /dev/dri/{card0,renderD128}
into pp-app, add it to render (992) + video (44) groups, and set
PHOTOPRISM_FFMPEG_ENCODER=vaapi. Hosts without a VA-API device just
skip the overlay (`-f docker-compose.yml -f docker-compose.gpu.yml`
becomes opt-in per deploy).

Drops video transcode + thumbnail generation from CPU to the iGPU
where present — large win for HEVC libraries. README documents the
flag; default behavior on the base compose is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:28:21 +02:00
70dc1b6bdf Merge pull request 'Mulimage 2.0' (#1) from new into main
Reviewed-on: #1
2026-05-21 22:48:54 +02:00
e3d4f6d92e web: SVG favicon (Greek lowercase mu) with light/dark adaptive fill
Use the Greek lowercase mu glyph as the app's favicon. The SVG
carries a `prefers-color-scheme` media query that flips the path
fill between near-black (light mode) and near-white (dark mode),
so it stays legible against any tab-bar background without an
extra browser hint.

Linked before the existing PNG so browsers that support SVG
favicons (Chrome 80+, Firefox 41+, Safari 9+) pick it up; the PNG
remains as a fallback. `apple-touch-icon` keeps the PNG since iOS
home-screen icons can't be SVG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:30:23 +02:00
9fc650fb12 web(sidebar): auto-expand folder tree ancestors of the active folder
When the timeline navigates to a nested folder (RightSidebar's
open-folder icon, URL hydration, back/forward), the LeftSidebar
already highlighted the matching row via filters.folderPath — but
if the parent folder was collapsed in the persisted openSet, the
highlighted row wasn't visible at all.

Each FolderTree instance now runs an effect that adds every
ancestor of the active path to its openSet on filter change. The
root instance expands the top-level ancestor first, which mounts
the next-depth FolderTree instance — and the same effect runs
there, cascading down to the leaf. Persisted to localStorage so
the expansion sticks across reloads.

Skipped in `readonly` mode (heap-convert picker has its own
selectedPath and shouldn't drive the sidebar state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:12:36 +02:00
29f7ad7073 web: deep-link from RightSidebar to folder/map + anchor-mode timeline + fix root count
RightSidebar:
- Folder + Location rows gain a small ArrowUpRight icon button that
  deep-links into the timeline / map view focused on the photo. OSM
  external link removed; the in-app map nav covers the same job.

Folder navigation:
- New navigateToFolder(path, { focusUid, focusTakenAt }) helper in the
  filters store; LeftSidebar's pickFolder collapses to a one-liner that
  reuses it.
- One-shot pending-focus stash carries both UID and TakenAt across the
  goto. URL-watch effect on the timeline consumes the stash so even
  same-folder navigations (where the filter doesn't change) get
  picked up.

Anchor-mode timeline query:
- listPhotosAround(q, takenAt, after, before) issues two parallel
  PhotoPrism calls (`after:<day-1>` oldest-first + `before:<day+1>`
  newest-first), merges + dedupes newest-first. Uses PhotoPrism's
  existing date-only DSL clauses — no server changes.
- When a deep-link stashes a TakenAt, page 0 of the photosQuery uses
  the merged window so the target photo is loaded even for photos
  buried past the standard newest-first cursor. Pages 1+ are disabled
  in anchor mode (PhotoPrism's day-precision cursor would infinite-loop
  on dense days; users see 120 around the target, refresh to drop the
  anchor).
- After page 0 lands, the existing scrollToIndex(targetIdx) expands the
  windowed render set + scrolls the tile into view.

Map view:
- /map honors `?lat=&lng=&zoom=&focus=` URL params, jumping to the
  photo's coordinates at zoom 17 instead of fitBounds-ing the full
  library. Params are stripped after first apply so a manual zoom-out
  + reload doesn't snap back.

LeftSidebar root count badge:
- Now matches what Cmd+A selects in the timeline. Old code used
  /config.count.all (library aggregate, includes archived/hidden/
  review). Switched to countPhotos('', { merged: true }) which counts
  the actual photo entries the timeline lists.
- countPhotos gains a `merged` option; with merged=true it returns the
  response body length instead of the X-Count header — PhotoPrism's
  X-Count is always the file-row count regardless of merged, so a
  HEIC + JPG companion pair inflated the badge to 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:04:23 +02:00
c134afe023 web(review): confidence-aware date guesser with combined filename + path signals
Refactor suggestDateFromPath to combine multiple signals instead of
trying patterns in priority order:

- Filename Y-M-D corroborated by path Y-M/Y-M-D → HIGH (filename-
  agrees-path). Fixes the case where a Samsung-style 20240226_xxx.jpg
  under 2024/02/ was returning the path-only 2024-02-01.
- Filename Y-M-D with no path signal → HIGH (filename-only).
- 10/13-digit Unix epoch in basename → HIGH (unix-timestamp) —
  covers WeChat (mmexport...) and FB saves.
- Path Y-M-D → HIGH (path-ymd).
- Path Y-M only → MEDIUM (path-ym-default-day, synthesised day=01).
  Sidebar row labels these "(estimated day)" so the user knows.

Filename parser now accepts `.` and space separators (covers macOS
screenshots, manual 2024.02.26 renames). Path parser accepts `.` too.
OriginalName participates as a secondary filename signal when present
and different from the on-disk basename.

Patterns we explicitly DO NOT parse, to avoid silent date flips:
DD-MM-YYYY / MM-DD-YYYY, 2-digit years, bare camera sequence numbers.

Add photoNameAndDir(p) helper next to primaryFile so RightSidebar,
BulkActionBar, photoActions, and gridKeyNav all derive {fileName,
path} the same way — fixes the bug where photo.FileName was
undefined on the single-photo detail endpoint and the basename branch
was being skipped entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:04:48 +02:00
a54d90a2d9 web(review): YYYY/MM path fallback + suggestion row below date + tighten 'a' gate
- suggestDateFromPath: when only year+month appear in the path
  (e.g. 2024/01/), synthesize day=01 so date-only foldering yields
  a usable suggestion instead of null.
- RightSidebar: move the suggestion row below the Taken-at input.
- BulkActionBar + gridKeyNav: show the "Accept date & Keep" button
  and fire the bare 'a' shortcut only when EVERY targeted photo has
  a path-derivable date — no more silent approve-without-fix for
  mixed selections.
- gridKeyNav: drop local cachedPhoto duplicate, use the shared one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:41:40 +02:00
97f51a05c4 web(review): scope date suggestion to EXIF Stripped tab + 'a' shortcut
- Gate the sidebar's "Suggested from path" row on /review?tab=stripped_exif
  instead of a per-photo TakenSrc heuristic — PhotoPrism stores a guessed
  TakenAt for stripped-EXIF photos too, so the heuristic was hiding the
  row even when a path-derived date was available.
- Same gate on the BulkActionBar's "Accept date & Keep" button.
- Extract acceptDateAndKeep() + cachedPhoto() into photoActions so the
  bar button and a new bare-'a' shortcut in gridKeyNav share one path.
- Show an 'A' kbd hint on the bar button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:31:04 +02:00
d1ddc48f81 web(review): switch cause tabs to PhotoGrid, add date-from-path suggestion
- CauseGroupCard now wraps PhotoGrid so selection, keyboard nav, and
  preview flow through the standard timeline plumbing. Per-tile hover
  Approve/Archive and the group-wide Approve all are gone — the bottom
  BulkActionBar's review-section Keep/Archive handle single + bulk.
- Low Resolution tab opts into a new PhotoTile dimensionBadge prop so
  WxH stays visible on each tile.
- New suggestDateFromPath util parses YYYY-MM-DD from filename or
  folder path. RightSidebar surfaces it as an amber Apply row above
  the Taken-at input whenever the photo lacks a trusted TakenAt.
- BulkActionBar gains a "Accept date & Keep" button (review section
  only) that patches each selected photo's TakenAt from its path
  suggestion when available, then approves.
- Drop the Same folder / Same camera / Same year strips and the
  RelatedStrip component from the metadata sidebar.

Also bundles in-progress Notes route + tile components and small
tweaks to LeftSidebar, DuplicatesView, CrossFolderGroupCard, and
photoprism.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:20:02 +02:00
55c870c155 web(sidebar): nest Hidden under Review submenu
Hidden is the resting place for photos dismissed during review, so it
groups naturally with the Review subitems. Stays a section-nav button
(keeping its scoped count badge); only Archive remains as a flat Manage
entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:44:43 +02:00
981328faff web(review): expandable Review group in sidebar, tabs become subitems
Mirrors the Tags affordance: chevron-only toggle, no /review landing
entry, navigation only via subitems (cause buckets + Stacks +
Cross-folder linked as /review?tab=<id>). Cause list reuses the
review-groups query so empty buckets stay hidden. The /review toolbar
drops the pill row and shows the active tab as a breadcrumb segment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:42:43 +02:00
e1707c314d preview: keep metadata sidebar mounted across photo changes
Previously the modal's aside was gated on `focusedPhotoQuery.data`, so
each arrow-skim unmounted the sidebar until the next photo's metadata
arrived — which reflowed the preview pane sideways. Now the aside is
always mounted while the modal is open; its contents swap between the
metadata panel and a small InlineLoader the same way the timeline's
right-aside does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:12 +02:00
64c0da794d web(sidebar): drop count badges from Map and Tags rows
Per-tag totals are already surfaced by the TagsBrowserSidebar, so the
main sidebar's Map/Tags rows stay as pure navigators. Also removes the
now-orphaned geo, marks, and keywords cache observers that only fed
those badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:24:53 +02:00
0f4e2e0b8f preview: LQIP + ±2 prefetch + bound action bar to its column
Preview pane paints instantly: a blurred copy of the same thumbnail the
grid loaded (cache hit) rides beneath the sharp fit_1280, which now
carries fetchpriority=high and decoding=async. A $effect prefetches
fit_1280 for the ±2 neighbours so arrow-skim hits the HTTP cache.
Carousel thumbs drop to fetchpriority=low so they yield to the main
image. Skeleton grid gains an mt-2 to breathe against the toolbar.
BulkActionBar moves inside the main column in both PreviewModal and the
/tags drill-in so it no longer stretches under the right sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:24:31 +02:00
ea1803ec2f web: 8-color label palette + VIDEO badge in carousel
Expand color labels from the 4-swatch Lightroom culling palette to 8
neutral colors (red/orange/yellow/green/teal/blue/purple/pink) with no
attached semantics, rendered as outlines that fill in when picked.
Carousel tiles now show a VIDEO badge, and the folder row in the right
sidebar always renders ("/" for root) instead of disappearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:05 +02:00
fc5f30fad1 fix(sidebar): use count.labels for Tags badge, drop bogus label:* query
PhotoPrism's q-DSL has no "has any label" predicate: `label:*` matches
every photo regardless of label, `label:<slug>` only matches that one
slug, and `keywords:*` behaves the same way. The prior `all:true label:*`
returned a 400 (and the earlier "drop all:true" follow-up made it return
the unfiltered library size, which then fed into tagsTotal and inflated
the parent Tags badge to ~library_size on admin sessions).

Switch labelsBadge to the precomputed `configQuery.count.labels` — the
same source the Tags sub-row's `tagCategoryCount('labels')` already
uses. The parent Tags badge now sums the exact same numbers the sub-rows
display: labels, keywords, people (distinct slugs/keywords/subjects)
plus ratings/colors (photos carrying each mark). Drop the wantScoped
short-circuit since the values are all library-wide now anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:41:07 +00:00
53 changed files with 3431 additions and 1244 deletions

View File

@@ -65,6 +65,19 @@ PP_GID=1000
# OIDC_ROLE=user
# ── USER LIBRARY ISOLATION ───────────────────────────────────────────────────
# Maps PhotoPrism usernames to originals-relative subdirectories so each
# user only sees their own photos. Format: comma-separated user:path pairs.
# The sidecar reconciler applies this to auth_users.base_path on boot and
# every 60s. Leave empty for single-user deployments.
#
# USER_BASEPATHS="alice:alice, bob:bob"
# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first
# boot. Rotate before any non-local deployment.
# SIDECAR_DB_PASSWORD=replace-at-m4-bringup
# ── LOGGING ──────────────────────────────────────────────────────────────────
PP_LOG_LEVEL=info

View File

@@ -0,0 +1,247 @@
# Plan: Populate `photos_users` to fix label isolation in PhotoPrism
**Date:** 2026-06-06
**Author:** Hermes Agent
**Status:** Draft
---
## 1. Goal
Fix the label isolation leak where a user with `base_path` set (e.g. `muli`) sees photos from other users' directories (e.g. `dtoro`) in PhotoPrism's labels view.
## 2. Current Context
### The problem
- PhotoPrism's `base_path` feature correctly scopes the main search (`/api/v1/photos`).
- Label views (`/api/v1/labels`) do **not** respect `base_path` — labels show photo counts and thumbnails from the entire library.
- User reports: "all photos on the main labels page are a mix of both" muli and dtoro.
### What we know
- **53 files changed** in the sidecar (Go + GORM, gorm.io/gorm v1.31.1).
- Sidecar has a working PhotoPrism DB connection via `PpDSN` (user: `photoprism`, schema: `photoprism.*`).
- The `photos_users` table exists in PhotoPrism's MariaDB schema but is **empty** (0 rows).
- Schema of `photos_users`:
```
photos_users:
uid varbinary(42) NOT NULL PRI (composite PK or single?)
user_uid varbinary(42) NOT NULL PRI
team_uid varbinary(42) YES MUL
perm int(10) unsigned YES
```
- Known user UIDs: `dtoro=utfetfdk0so2z9zl`, `muli=utg7jjbd8iwaghn6`
- Known base paths: `dtoro→dtoro`, `muli→muli`
- The `photo_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/Memories/...`
- Currently: ~88K photos, ~52K files indexed.
### The sidecar's current reconciler (`users.go`)
- Runs every 60s.
- Only calls `UPDATE auth_users SET base_path = ? WHERE user_name = ?`.
- Does **not** touch `photos_users`.
### Unknowns
1. **Does PhotoPrism use `photos_users` for general label filtering?** The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignore `photos_users` in label queries.
2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries.
3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow.
4. **`perm` values**: Unclear what `perm` value grants "view" access. Likely a bitmap (bit 0 = view).
## 3. Proposed Approach
### Phase 1: Investigate (prove the approach works before building)
**Step 1.1: Insert test rows into `photos_users` manually**
On the production DB, insert a few `photos_users` entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed `perm` value (e.g. `1` = view).
Then check:
- Does muli see fewer photos now? (If `photos_users` works as an exclusive ACL, yes.)
- Does the dtoro photo with a `photos_users` entry for muli show up for muli?
- Does the label view change?
**Step 1.2: Test with `perm` variations**
If `perm=1` does nothing, try `perm=2`, `perm=7`, or `perm=15` (common Unix-ish bitmap patterns).
**Step 1.3: Examine PhotoPrism source**
Check PhotoPrism's search/label code to confirm whether `photos_users` is joined in label queries. This tells us definitively whether the approach is viable.
### Phase 2: Build (if Phase 1 confirms the approach works)
**Step 2.1: Add `photos_users` GORM model**
New struct in `db.go` or a new file `perms.go`:
```go
type PhotoUser struct {
PhotoUID string `gorm:"primaryKey;size:42;column:uid"`
UserUID string `gorm:"primaryKey;size:42;column:user_uid"`
TeamUID string `gorm:"size:42;column:team_uid"`
Perm int `gorm:"column:perm"`
}
func (PhotoUser) TableName() string { return "photos_users" }
```
Note: GORM `AutoMigrate` is called on `mule_sidecar` schema, not `photoprism.*`. The `photos_users` table already exists in the `photoprism` schema — we only query/insert, never migrate.
**Step 2.2: Add `reconcilePhotoUsers` function**
New function in a new file `perms.go` alongside `users.go`. Signature:
```go
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
```
Logic:
1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`.
2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`.
3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1).
4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency.
5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up.
**Step 2.3: Wire into reconciler loop**
Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`.
```go
func apply() {
reconcileUserBasepaths(...)
reconcilePhotoUsers(...)
}
```
**Step 2.4: Handle re-index edge cases**
- When new photos are indexed, they won't have `photos_users` entries until the next 60s tick.
- Could add a webhook or a one-shot trigger after PhotoPrism's index completes.
- Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag).
### Phase 3: Validate (if Phase 1 confirms)
1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
2. Rebuild the Docker image and restart the sidecar.
3. Check `photos_users` has expected rows.
4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone.
5. Log in as `dtoro` — verify still sees own photos.
6. Verify no regression: search, album, folder views still work for both users.
## 4. Files Likely to Change
| File | Change |
|------|--------|
| `sidecar/perms.go` | **New file**`PhotoUser` model, `reconcilePhotoUsers` function |
| `sidecar/db.go` | Add `photos_users`-related constants/helpers (optional) |
| `sidecar/users.go` | Extend `reconcileUserBasepaths` or add a phase to the existing reconciler |
| `sidecar/main.go` | Wire the new reconciler phase (minor — call from existing ticker) |
| `sidecar/Dockerfile` | Unchanged (Go build picks up new `.go` files automatically) |
## 5. Tests & Validation
1. **Build check**: `go build ./...` from `sidecar/`
2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...`
3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user.
4. **Label isolation check**: Browse labels as each user — confirm no cross-user leaks.
## 6. Source Code Analysis (Completed)
### How base_path scoping works in PhotoPrism
Found the critical function `ScopePhotosForSession` in `internal/entity/search/photos_scope.go`:
```go
func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB {
// Admin/library role → no scoping needed
if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
return stmt
}
user := sess.GetUser()
if basePath := user.GetBasePath(); basePath == "" {
return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...)
} else {
return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?",
..., basePath, basePath + "/%")
}
}
```
Key: base_path filtering is done by adding `WHERE photos.photo_path LIKE 'muli/%'` to the SQL query. It is **NOT** done via `photos_users`.
### How endpoints use base_path
| Endpoint | Function | Applies base_path? |
|----------|----------|-------------------|
| `GET /api/v1/photos` | `SearchPhotos``UserPhotos``searchPhotos`**`ScopePhotosForSession`** | ✅ Yes |
| `GET /api/v1/labels` | `SearchLabels``search.Labels(frm)` — no session passed | ❌ **No** |
| Review tab | Uses `GET /api/v1/photos?quality=3` → goes through `ScopePhotosForSession` | ✅ Should scope |
| Archive tab | Uses `GET /api/v1/photos?archived=true` → goes through `ScopePhotosForSession` | ✅ Should scope |
| Albums | TBD — depends on whether they use `ScopePhotosForSession` | ⚠️ Unknown |
### The `photos_users` table
Found in `internal/entity/photo_user.go`:
```go
type PhotoUser struct {
UID string // photo_uid
UserUID string // user_uid
TeamUID string // team_uid
Perm uint // permission bitmap
}
```
This table is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. It is only used for **explicit sharing** (via `FirstOrCreatePhotoUser` called when sharing a specific photo with another user).
**Conclusion: Populating `photos_users` will NOT fix the label, review, or archive tab isolation.** PhotoPrism does not consult this table for any of these queries.
### Why review/archive might show cross-user photos
Since review and archive use `GET /api/v1/photos` which goes through `ScopePhotosForSession`, they **should** be scoped. The issue might be:
1. **Pre-computed counts** in the sidebar tabs show total numbers across all users
2. **Label thumbnails** and category summaries are computed from the `labels` table which is global
3. The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts
### DB experiment results
Confirmed `photos_users` is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with `perm=1`. Label API response unchanged — `photo_count` values remained the same (Dog: 733, Cat: 57), confirming labels ignore `photos_users`.
## 7. Updated Recommendation
**Abandon the `photos_users` approach.** It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries.
### Real fix options
1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that:
- Accepts the caller's `X-Auth-Token` (already validated by `requireSession`)
- Forwards the request to PhotoPrism's `/api/v1/labels`
- **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path`
- Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label)
- Also filter `Count` values in the sidebar summary response
**Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path.
2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
3. **Accept the limitation** — Labels show cross-user thumbnails/counts but the actual photo list is scoped.
### Implementation sketch for option 1
```
sidecar/
├── proxy.go # New file
│ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path
│ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count
│ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count
```
The SvelteKit frontend would call `/api/sidecar/labels` instead of `/api/v1/labels`.
### Clean up: remove test rows from photos_users
Since the approach won't work, remove the test rows inserted during Phase 1:
```sql
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';
```

View File

@@ -0,0 +1,301 @@
# Plan: Fix user isolation in PhotoPrism — labels, review, and archive views
**Date:** 2026-06-06
**Author:** Hermes Agent
**Status:** Draft
---
## 1. Goal
Fix the three views where a user with `base_path` set (e.g. `muli`) sees photos from other users (e.g. `dtoro`):
1. **Labels** — labels list + label drill-down show all library photos
2. **Review** — photos needing review tab shows cross-user photos
3. **Archive** — archived photos tab shows cross-user photos
## 2. Current Context & Source Analysis
### 2.1 How base_path scoping works
PhotoPrism's `ScopePhotosForSession` (in `internal/entity/search/photos_scope.go`) is the only function that enforces user isolation. It adds `WHERE photos.photo_path = '<basePath>' OR photos.photo_path LIKE '<basePath>/%'` to the SQL query.
This is called by `searchPhotos()` — used by the **`GET /api/v1/photos`** endpoint (search, timeline, folders).
### 2.2 How endpoints use base_path
| View | Endpoint | Function chain | Applies base_path? |
|------|----------|----------------|-------------------|
| Main timeline | `GET /api/v1/photos` | `SearchPhotos``searchPhotos``ScopePhotosForSession` | ✅ Yes |
| Folders | `GET /api/v1/photos` with path filter | Same chain | ✅ Yes |
| **Labels** | `GET /api/v1/labels` | `SearchLabels``search.Labels(frm)`**no session** | ❌ **No** — queries `labels` table directly |
| Label drill-down | `GET /api/v1/photos?label=X` | Uses `searchPhotos``ScopePhotosForSession` | ✅ Should scope (if label= param doesn't bypass) |
| **Review tab** | `GET /api/v1/photos?q=review:true` | `searchPhotos``ScopePhotosForSession` | ✅ Should scope, BUT... |
| **Archive tab** | `GET /api/v1/photos?q=archived:true` | Same | ✅ Should scope, BUT... |
### 2.3 The review/archive problem: ACL overrides
In `searchPhotosForm()` (internal/api/photos_search.go):
```go
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
frm.Quality = 3
}
```
For role=user, `Deny(ActionManage)` → true → sets `frm.Quality = 3` (minimum quality).
Then in `searchPhotos()` (internal/entity/search/photos.go):
```go
if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) {
frm.Archived = false
frm.Review = false
}
```
For role=user, `Deny(ActionDelete)` → true → **overrides `review:true` and `archived:true` to false**.
So the review and archive filters are **completely ignored** for the `user` role. The frontend sends `review:true` but the server discards it. The result: the review/archive tabs show ALL photos scoped by base_path (no quality/review/archive filter), which means basically the same as the main timeline.
### 2.4 Label problem: no session scoping at all
`search.Labels()` queries the `labels` table directly with a `WHERE photo_count > 0` clause. There is no session parameter, no `ScopePhotosForSession`, and no base_path or user filtering whatsoever. Labels are **library-wide** in PhotoPrism.
The label drill-down (click into a label) uses `GET /api/v1/photos?label=X` which DOES go through `ScopePhotosForSession`, so the photo list itself should be scoped — but the label thumbnails, counts, and covers are global.
### 2.5 ACL GrantDefaults — missing RoleUser entry
```go
var GrantDefaults = Roles{
RoleAdmin: GrantFullAccess, // FullAccess = AccessLibrary + everything
RoleGuest: GrantReactShared, // Only shared content
RoleVisitor: GrantViewShared, // Only shared content
RolePortal: GrantFullAccess,
RoleClient: GrantFullAccess,
// RoleUser and RoleViewer are NOT listed → fallback to RoleDefault (also missing) → denied
}
```
Because `RoleUser` is absent from `GrantDefaults`, the `Allow()` function falls back to `RoleDefault` which is also absent → returns `false` for all permissions. This means:
- `ScopePhotosForSession` correctly enters the `base_path` branch (good — user is isolated)
- BUT `ActionDelete` is denied → review/archive filters are forced off (bad — can't browse review/archive)
### 2.6 The `photos_users` table — ruled out
`internal/entity/photo_user.go` defines `PhotoUser` but it is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. Populating it won't fix any of these issues.
## 3. Proposed Approach
### Phase 1: Sidecar proxy for labels (direct fix)
Extend the sidecar to expose a **scoped labels endpoint**:
```
GET /api/sidecar/labels → proxies to GET /api/v1/labels → filters by base_path
```
**How it works:**
1. Sidecar receives the caller's `X-Auth-Token`
2. `requireSession` middleware resolves the token → returns user's `BasePath`
3. Sidecar makes the same `/api/v1/labels` request to PhotoPrism (using the caller's token)
4. **Filter step**: for each label in the response, query the DB to count photos with that label AND `photo_path LIKE '<base_path>/%'`
5. Return filtered labels with corrected `PhotoCount` and `Thumb`
**Implementation:**
New file `sidecar/handlers_labels.go`:
```go
// handleLabels proxies to PP's /api/v1/labels, then post-filters
// counts and thumbnails by the caller's base_path.
func handleLabels(pp *ppClient, ppDSN string) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
user := ctxUser(c) // resolved from session, includes BasePath
// 1. Get raw labels from PhotoPrism
resp, _ := pp.call(c, "GET", "/api/v1/labels?"+c.Request.URL.RawQuery, token, nil)
// 2. Decode labels
var labels []PpLabel
json.Unmarshal(resp.Body, &labels)
// 3. For each label, recalculate count for this user's base_path
for i, l := range labels {
// Count photos with this label AND where photo_path matches base_path
var count int
db.Raw(`SELECT COUNT(*) FROM photos_labels pl
JOIN photos p ON pl.photo_uid = p.photo_uid
WHERE pl.label_uid = ? AND p.photo_path LIKE ?`,
l.UID, user.BasePath+"/%").Scan(&count)
labels[i].PhotoCount = count
// If count is 0, the thumb from the global label doesn't apply
// Could also update thumb to a user-scoped one
}
c.JSON(http.StatusOK, labels)
}
}
```
**Frontend change:** Update the label query in `web/src/routes/tags/[category]/[[value]]/+page.svelte` to call `/api/sidecar/labels` instead of `/api/v1/labels`.
### Phase 2: Fix review/archive — skip the ACL override
Two options:
**Option A (Recommended): Sidecar proxy for photos search**
Extend the sidecar with:
```
GET /api/sidecar/photos → proxies to GET /api/v1/photos → adds path filter
```
The sidecar intercepts the photos request and adds the `path:<basePath>` query parameter to PhotoPrism's API call. This forces PhotoPrism to add `WHERE photo_path LIKE '<base_path>/%'`.
For review/archive, the sidecar also adds `review:true` or `archived:true` BEFORE the ACL override happens (since the sidecar doesn't hit the ACL code).
**Option B: Custom frontend query**
The frontend explicitly adds `path:muli` to the query string for review/archive tabs:
```
GET /api/v1/photos?q=review:true path:muli&count=50
```
The `path` filter is a standard PhotoPrism search operator that adds `WHERE photos.photo_path = '<path>'`. But this only matches the exact path, not `path/%` (subdirectories). The `path:` operator does `photo_path = ?` (exact match) per the code at line 668.
**Option A is better** because:
- Works for all users without frontend changes
- Can add the proper `LIKE` prefix match
- Centralized logic in the sidecar
### Phase 3: Sidecar proxy for sidebar counts
The session response (or `GET /api/v1/config`) includes library-wide counts:
```json
"count": {
"review": 248,
"archived": 94,
"all": 88203,
"photos": 88000
}
```
These show the TOTAL across all users. The sidecar can proxy this and recalculate counts per base_path.
## 4. Step-by-step Plan
### Step 1: Sidecar — labels proxy
Files: `sidecar/handlers_labels.go` (new), `sidecar/main.go` (route wiring)
1. New types: `PpLabel` (mirrors PhotoPrism's label response shape)
2. Handler function `handleLabels()` that:
- Validates token via `requireSession`
- Gets `BasePath` from session
- Calls PhotoPrism's `/api/v1/labels`
- For each label, queries photos_labels + photos to count user-scoped photos
- Returns filtered labels
3. Wire route: `auth.GET("/labels", handleLabels(...))` in `main.go`
4. Frontend: change label fetch URL from `/api/v1/labels` to `/api/sidecar/labels`
### Step 2: Sidecar — photos proxy (review/archive fix)
Files: `sidecar/handlers_photos.go` (new), `sidecar/main.go` (route wiring)
1. Handler function `handlePhotos()` that:
- Validates token
- Gets `BasePath` from session
- Parses the query string to detect `review:true` or `archived:true`
- Forwards to PhotoPrism's `/api/v1/photos` with `path:<basePath>` added to query
- For review/archive, also ensures `review/archived` filter is NOT stripped
- Returns PhotoPrism's response
2. Two implementation variants:
**Variant A** (simpler): add `path:<basePath>` to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's `path:` operator does exact match). Might miss photos in subdirectories.
**Variant B** (correct): Forward the query without path, then post-filter the response to remove photos whose `photo_path` doesn't match `basePath/%`. This is more robust.
### Step 3: Validation
1. Build sidecar: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
2. Rebuild Docker image: `docker compose build sidecar`
3. Restart sidecar: `docker compose up -d sidecar`
4. Test labels as muli — verify only muli's labels appear
5. Test review tab as muli — verify only muli's photos needing review appear
6. Test archive tab as muli — verify only muli's archived photos appear
7. Test same views as admin — verify dtoro still sees all
## 5. Files Likely to Change
| File | Change |
|------|--------|
| `sidecar/handlers_labels.go` | **New** — label proxy handler |
| `sidecar/handlers_photos.go` | **New** — photos proxy handler (or merged into one proxy.go) |
| `sidecar/handlers_folder.go` | Reference for existing handler patterns |
| `sidecar/main.go` | Wire new routes under `auth` group |
| `sidecar/pp.go` | May need new helper methods for label/photo API calls |
| `sidecar/users.go` | No change |
| `sidecar/db.go` | May add types for PpLabel, PpPhoto |
| `web/src/routes/tags/[category]/[[value]]/+page.svelte` | Change label fetch URL |
| `web/src/lib/stores/filters.svelte.ts` | Possibly change how review/archive queries are built |
## 6. Tests & Validation
**Build**: `cd sidecar && go build ./... && go vet ./...`
**Manual validation on LXC 120:**
```bash
# Test labels endpoint
curl -s "http://localhost:8000/api/sidecar/labels?count=5" \
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(l.get('Name','?'),l.get('PhotoCount')) for l in d[:5]]"
# Test photos endpoint with review
curl -s "http://localhost:8000/api/sidecar/photos?q=review:true&count=5" \
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'{len(d)} photos')"
# Verify vs. admin token — counts should differ
```
**Cross-user check:** Log in as `muli` and `dtoro` in separate browser sessions. Verify:
- Labels show different counts per user
- Review photos are scoped per user
- Archive photos are scoped per user
## 7. Risks, Tradeoffs & Open Questions
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| Sidecar proxying adds latency | Slower page loads | Labels are small payloads; single DB query per label is fast |
| Frontend needs URL changes | Breaks if not updated | Do frontend change alongside sidecar deploy |
| Photo count queries on every label request | DB load | Cache results for 30s in the sidecar |
| PhotoPrism's label `PhotoCount` is stale | Mismatch with actual count | Acceptable — PhotoPrism's count is already cached |
| Review/archive fix depends on how PhotoPrism handles `path:` operator | Photos in subdirs missed | Use Variant B (post-filter by path prefix) |
### Open Questions
- **Q1**: For review/archive — is the user seeing dtoro's photos in the *grid* or only the *sidebar counts*? Need to verify actual API response vs what the frontend renders.
- **Q2**: What's the performance impact of running `SELECT COUNT(*) FROM photos_labels ... JOIN photos ...` for every label in the response? (Labels list is typically short, < 100)
- **Q3**: Does the frontend cache the label response aggressively? Need to invalidate cache on user switch.
- **Q4**: For the `path:` operator — does it do exact match or LIKE? From source: `WHERE photos.photo_path = ?` — exact match only.
### Tradeoffs
- **Sidecar proxy vs. frontend-only**: Proxy centralizes logic but adds network hop. Frontend-only is faster but more complex (every route needs path filtering).
- **Label count accuracy**: Recalculated per-user counts will differ from the library-wide counts. This is intentional — labels are scoped now.
- **Sidecar vs. patching PhotoPrism**: Sidecar approach is non-invasive (no fork/build of PP). PhotoPrism patch would be cleaner but requires maintaining a fork.
## 8. Recommendation
1. **Build the labels proxy** (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
2. **Investigate review/archive leak** first — run the actual API query as muli to confirm whether the photos search is actually scoped. The code analysis says it should be, but the user reports otherwise. If confirmed as a real leak, implement the sidecar photos proxy (Phase 2)
3. **Sidebar counts** (Phase 3) — lower priority, can be done after labels and review/archive are fixed
Before building, confirm with the user whether they see cross-user photos in the actual grid or only in the sidebar counts for review/archive.

View File

@@ -0,0 +1,227 @@
# Plan: Fix remaining user isolation issues — 404 errors and folder tree
**Date:** 2026-06-06
**Author:** Hermes Agent
**Status:** Draft
---
## 1. Goal
Fix the remaining issues after deploying the sidecar scoping proxy:
1. **404 on photo grid** — "Request failed with status code 404" in private window
2. **Folder tree shows other users** — on first load, the library tree lists other users' folders; a refresh fixes it
## 2. Current Context
### What's deployed
| Component | Status |
|-----------|--------|
| Sidecar labels proxy (`/api/sidecar/labels`) | ✅ Working |
| Sidecar counts proxy (`/api/sidecar/counts`) | ✅ Working |
| Sidecar timeline proxy (`/api/sidecar/timeline`) | ✅ Working through Caddy |
| Caddy fallback for `/api/v1/api/sidecar/*` | ✅ Working |
| Frontend rebuild with `sidecar` axios instance | ✅ Built and deployed |
### Verified working via Caddy
```bash
# Through public URL with valid admin token
curl https://photos.hubris.network/api/sidecar/timeline?count=1 → HTTP 200
curl https://photos.hubris.network/api/v1/photos?count=1 → HTTP 200
```
Both endpoints return 200 when tested directly through Caddy with a valid token.
### Reported issues
1. **404 on photo grid** — even in private window (no cache interference)
2. **Folder tree shows other users' folders** on first load, fixed by refresh
## 3. Root Cause Analysis
### Issue 1: 404 on photo grid
The `sidecar` axios instance (`baseURL: ''`) is missing the **response interceptor** that:
- Handles 401 → clears session → redirects to login
- Re-throws with meaningful error message
The `http` instance (for `/api/v1` endpoints) has this interceptor. Without it on `sidecar`:
- If the sidecar returns a non-2xx (401, 502 from upstream PP failure, etc.), axios throws a raw error
- The TanStack Query error boundary catches it and shows "Request failed with status code <status>"
- Very likely the sidecar is returning 401 on some calls (token expired / session not yet established) and the error message might show 404 because Caddy's catch-all returns 404 when a matcher doesn't find a route
**Hypothesis:** During OIDC login flow, the frontend may make some sidecar calls BEFORE the session is fully established (token loaded into `session.accessToken`). The `sidecar` interceptor checks `session.accessToken` but it might be null. Then the request to `/api/sidecar/timeline` has no auth header → sidecar returns 401 → no response interceptor → raw error.
**Fix:** Add the same 401 → login redirect interceptor to the `sidecar` instance.
### Issue 2: Folder tree shows other users
`listFolders()` calls `http.get('/folders/originals')` which hits PhotoPrism directly. PhotoPrism returns **all folders across the library** regardless of user. The frontend then filters by `userBasePath()` on the result:
```typescript
const bp = userBasePath();
if (bp === '') return folders; // On first load, bp might be empty!
return folders.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
```
On first load, `userBasePath()` returns `""` because:
1. The session data is loaded asynchronously
2. `session.user.BasePath` might not yet be populated when `listFolders` fires
3. The TanStack Query cache from a previous session might still have old data
After a refresh, the session is fully loaded, and `userBasePath()` returns the correct value.
A secondary issue: the `http` interceptor's 401 handler clears the session on 401. If the session expires during the app's lifetime, all subsequent requests fail with 401.
## 4. Proposed Approach
### Phase 1: Fix 404 — add response interceptor to sidecar
**File:** `web/src/lib/services/photoprism.ts`
Add the same 401 → login redirect interceptor to `sidecar` as already exists on `http`:
```typescript
sidecar.interceptors.response.use(
(r) => r,
(err: AxiosError) => {
if (err.response?.status === 401 && browser) {
clearSession();
const url = err.config?.url ?? '';
if (!url.endsWith('/session')) {
void goto('/login', { replaceState: true });
}
}
return Promise.reject(err);
}
);
```
### Phase 2: Fix folder tree — sidecar folder proxy
**File:** `sidecar/handlers_folders.go` (new)
Add a sidecar endpoint that proxies `/folders/originals` and post-filters by BasePath:
```
GET /api/sidecar/folders → proxies to GET /api/v1/folders/originals
→ removes folders not under user's base_path
→ returns filtered list
```
This avoids the timing issue entirely by filtering on the server side.
**Alternative (simpler):** Fix the frontend timing issue by ensuring `listFolders` doesn't fire until the session is ready.
### Phase 3: Change folder tree in frontend
**File:** `web/src/lib/services/photoprism.ts`
Change `listFolders()` to use `sidecar` instance and call `/api/sidecar/folders`:
```typescript
export async function listFolders(): Promise<PpFolder[]> {
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } }
);
const bp = userBasePath();
const folders = data.folders ?? [];
if (bp === '') return folders;
return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) }));
}
```
## 5. Step-by-step Plan
### Step 1: Add sidecar response interceptor
1. Edit `web/src/lib/services/photoprism.ts`
2. Add the 401-handling response interceptor to the `sidecar` instance
3. The interceptor mirrors the existing `http` response interceptor exactly
### Step 2: Rebuild frontend
```bash
cd /opt/mule-image/web && npm run build
```
### Step 3: (Optional) Add sidecar folder proxy
1. New file `sidecar/handlers_folders_proxy.go`
2. Handler similar to `handlePhotos` — proxies to `/api/v1/folders/originals`, post-filters by `Path` prefix
3. Wire route in `main.go`: `auth.GET("/folders", handleFoldersProxy(pp))`
4. Build Docker image, restart sidecar
### Step 4: Update listFolders to use sidecar
1. Change `listFolders()` to use `sidecar` instance
2. Call `/api/sidecar/folders` instead of `/folders/originals`
### Step 5: Rebuild + validate
```bash
# Rebuild frontend
cd /opt/mule-image/web && npm run build
# Test through Caddy
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1" \
-H "X-Auth-Token: <token>" | head -c 200
# Verify folders
curl -s "https://photos.hubris.network/api/sidecar/folders" \
-H "X-Auth-Token: <token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps(d[:3],indent=2))"
```
### Step 6: Commit
```bash
git add -A && git commit -m "fix: add sidecar response interceptor + folder proxy" && git push
```
## 6. Files Likely to Change
| File | Change |
|------|--------|
| `web/src/lib/services/photoprism.ts` | Add response interceptor to sidecar instance; change listFolders URL |
| `sidecar/handlers_folders_proxy.go` | **New** — folder proxy handler |
| `sidecar/main.go` | Wire folder proxy route |
## 7. Tests & Validation
**Manual:**
1. Open private window → navigate to photos.hubris.network
2. Log in as muli via Authentik OIDC
3. Verify photo grid loads without 404
4. Verify folder tree shows only muli's folders
5. Switch to dtoro account → verify folders/timeline scoped to dtoro
**API tests:**
```bash
# Sidecar timeline (no token → 401 redirect)
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1"
# Sidecar folders
curl -s "https://photos.hubris.network/api/sidecar/folders"
```
## 8. Risks & Open Questions
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| Sidecar returns 401 during OIDC login flow before session is ready | 404 showing instead of graceful redirect | Add response interceptor in Phase 1 |
| Folder proxy adds latency | Slower folder tree loading | Minimal — single proxy call, same as PP direct |
| `userBasePath()` timing issue in listFolders persists even with sidecar | Folder tree still shows wrong folders on first load | Sidecar filter is server-side → no timing dependency |
### Open Questions
- **Q1**: Are there other API calls that bypass the `sidecar` instance and might also be unscoped? (e.g., `listSubjects`, `listGeo`, etc.)
- **Q2**: Does the sidecar need a folder proxy, or is the timing fix sufficient? The timing fix (delaying `listFolders` until session is ready) is simpler but fragile.
- **Q3**: Could the 404 be from Caddy's catch-all returning 404 when the sidecar isn't reachable? The Caddy fallback timeout for the sidecar might need tuning.

View File

@@ -115,6 +115,7 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
.
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
├── .env.example required env vars (copy to .env)
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
├── pp/ PhotoPrism bind-mounted state (storage, import)
@@ -122,4 +123,18 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
└── web/ SvelteKit frontend
```
## GPU video acceleration (optional)
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
[`docker-compose.gpu.yml`](docker-compose.gpu.yml) to hand `/dev/dri/*`
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
perf win for video thumbnails and HEVC→H.264 transcodes:
```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
```
Set `PP_FFMPEG_ENCODER=vaapi` in `.env` (default for the overlay). Verify
with `docker exec pp-app photoprism show config | grep -i ffmpeg`.
[pp]: https://photoprism.app/

24
docker-compose.gpu.yml Normal file
View File

@@ -0,0 +1,24 @@
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
# ships VA-API-enabled ffmpeg; this file just wires the device + group
# membership + encoder selection. Layered in by the deploy script on
# hosts where /dev/dri/renderD128 exists.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
services:
photoprism:
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
- /dev/dri/card0:/dev/dri/card0
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
# container user (PP_UID:PP_GID, typically 33:10000) is not in
# these groups by default; group_add grants access to the device
# nodes without changing the primary user.
group_add:
- "992"
- "44"
environment:
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}

View File

@@ -42,7 +42,7 @@ services:
# 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"]
test: ["CMD", "/usr/bin/mariadb-admin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 12

View File

@@ -11,7 +11,7 @@ import (
// is the only authority, and we probe PhotoPrism with it before doing any
// destructive work. The handler reads the validated token off the context
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
// operation.
// operation. The resolved username is available via ctxUserName.
func requireSession(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("X-Auth-Token")
@@ -19,11 +19,15 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
return
}
if !pp.validateSession(c.Request.Context(), token) {
user := pp.resolveSession(c.Request.Context(), token)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return
}
c.Set("token", token)
c.Set("userName", user.UserName)
c.Set("userUID", user.UserUID)
c.Set("basePath", user.BasePath)
c.Next()
}
}
@@ -42,3 +46,42 @@ func ctxToken(c *gin.Context) string {
}
return s
}
// ctxUserName returns the PhotoPrism username resolved by requireSession.
func ctxUserName(c *gin.Context) string {
v, ok := c.Get("userName")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
func ctxUserUID(c *gin.Context) string {
v, ok := c.Get("userUID")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
func ctxBasePath(c *gin.Context) string {
v, ok := c.Get("basePath")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

View File

@@ -9,13 +9,13 @@ import (
)
// Mark mirrors the per-photo extras the web client stores via the marks
// endpoints — rating + four-colour label. PhotoUID is the row key; both
// payload fields are nullable so the sparse "no rating / no colour" state
// round-trips cleanly. The Node prototype kept this in a JSON file; we
// migrate to MariaDB here so the M4 sharing work has a real table to
// extend.
// endpoints — rating + four-colour label. Composite primary key
// (photo_uid, user_name) so each user has independent marks. Both payload
// fields are nullable so the sparse "no rating / no colour" state
// round-trips cleanly.
type Mark struct {
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`

View File

@@ -0,0 +1,70 @@
package main
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handleFoldersProxy proxies PhotoPrism's /api/v1/folders/originals and
// post-filters by the caller's BasePath so the folder tree only shows
// folders under the user's library root.
//
// Route: GET /api/sidecar/folders (behind requireSession)
func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward query params to PhotoPrism.
query := c.Request.URL.RawQuery
if query == "" {
query = "recursive=true&uncached=true&files=false"
}
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/folders/originals?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream folders request failed"})
return
}
// Decode the response — PhotoPrism returns { folders: [...] }.
var payload struct {
Folders []map[string]any `json:"folders"`
}
if err := json.Unmarshal(resp.Body, &payload); err != nil {
c.Data(resp.Status, "application/json", resp.Body)
return
}
// If the user has no BasePath (admin/empty), return as-is.
if basePath == "" {
c.JSON(http.StatusOK, payload)
return
}
prefix := basePath + "/"
// Post-filter folders by Path field only — frontend handles BasePath
// prefix stripping via toUserPath().
filtered := make([]map[string]any, 0, len(payload.Folders))
for _, f := range payload.Folders {
rawPath, ok := f["Path"]
if !ok {
continue
}
pathStr, ok := rawPath.(string)
if !ok {
continue
}
// Keep only folders under the user's base path.
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
filtered = append(filtered, f)
}
}
c.JSON(http.StatusOK, gin.H{"folders": filtered})
}
}

171
sidecar/handlers_labels.go Normal file
View File

@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns.
// We decode enough to filter + recalculate PhotoCount; fields the client
// doesn't render are skipped for token efficiency.
type PpLabel struct {
UID string `json:"UID"`
Name string `json:"Name"`
Slug string `json:"Slug"`
CustomSlug string `json:"CustomSlug"`
Priority int `json:"Priority"`
Favorite bool `json:"Favorite"`
PhotoCount int `json:"PhotoCount"`
Thumb string `json:"Thumb"`
CreatedAt string `json:"CreatedAt"`
UpdatedAt string `json:"UpdatedAt"`
}
// handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters
// each label's PhotoCount (and removes labels with zero count) so they
// reflect only photos under the caller's BasePath.
//
// Route: GET /api/sidecar/labels (behind requireSession)
func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward the query string (count, offset, q, all, …) to PhotoPrism.
query := c.Request.URL.RawQuery
// Call PhotoPrism's labels endpoint using the caller's token.
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"})
return
}
// Decode labels.
var labels []PpLabel
if err := json.Unmarshal(resp.Body, &labels); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"})
return
}
// If the user has no BasePath (admin/empty), return labels as-is.
if basePath == "" || ppDb == nil {
c.JSON(http.StatusOK, labels)
return
}
// One query: count + a representative scoped thumb for every label
// the user can see. Replaces N per-label queries with a single JOIN.
prefix := basePath + "/%"
type labelStat struct {
LabelUID string `gorm:"column:label_uid"`
Cnt int64 `gorm:"column:cnt"`
ThumbHash string `gorm:"column:thumb_hash"`
}
var stats []labelStat
if err := ppDb.Raw(`
SELECT lb.label_uid AS label_uid,
COUNT(DISTINCT p.id) AS cnt,
COALESCE(MIN(f.file_hash), '') AS thumb_hash
FROM photos_labels pl
JOIN photos p ON pl.photo_id = p.id
JOIN labels lb ON pl.label_id = lb.id
LEFT JOIN files f ON f.photo_uid = p.photo_uid
AND f.file_primary = 1
AND f.file_missing = 0
WHERE (p.photo_path = ? OR p.photo_path LIKE ?)
AND p.deleted_at IS NULL
GROUP BY lb.label_uid
HAVING cnt > 0
`, basePath, prefix).Scan(&stats).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "label stats query failed"})
return
}
cntMap := make(map[string]int64, len(stats))
thumbMap := make(map[string]string, len(stats))
for _, s := range stats {
cntMap[s.LabelUID] = s.Cnt
thumbMap[s.LabelUID] = s.ThumbHash
}
filtered := make([]PpLabel, 0, len(stats))
for _, l := range labels {
cnt, ok := cntMap[l.UID]
if !ok || cnt == 0 {
continue
}
l.PhotoCount = int(cnt)
if th := thumbMap[l.UID]; th != "" {
l.Thumb = th
}
filtered = append(filtered, l)
}
c.JSON(http.StatusOK, filtered)
}
}
// Now also handle the session/config count scoping.
// PpCounts mirrors PhotoPrism's session config.count block that drives
// the sidebar badges (review, archive, all, etc.).
type PpCounts struct {
All int `json:"all"`
Photos int `json:"photos"`
Media int `json:"media"`
Videos int `json:"videos"`
Review int `json:"review"`
Archived int `json:"archived"`
Hidden int `json:"hidden"`
Private int `json:"private"`
Favorites int `json:"favorites"`
}
// handleScopedCounts returns user-scoped counts for review/archive/all
// so the sidebar badges match what the user actually sees.
//
// Route: GET /api/sidecar/counts (behind requireSession)
func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
basePath := ctxBasePath(c)
if basePath == "" || ppDb == nil {
// Admin or no DB — can't scope, return empty.
c.JSON(http.StatusOK, PpCounts{})
return
}
prefix := basePath + "/%"
pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)"
args := []any{basePath, prefix}
var counts PpCounts
// All non-deleted photos in this user's scope.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All)
// Photos needing review (quality < 3).
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review)
// Archived (soft-deleted) photos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived)
// Private photos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private)
// Photos (type image).
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos)
// Videos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos)
// Favorites.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites)
c.JSON(http.StatusOK, counts)
}
}

View File

@@ -10,13 +10,18 @@ import (
"gorm.io/gorm"
)
// validColors is the four-color palette mule-image always shipped. The
// empty string is the explicit "clear color" sentinel.
// validColors is the color palette the web client offers (COLOR_SWATCHES in
// web/src/lib/utils/tagGroups.ts) — keep the two in sync. The empty string is
// the explicit "clear color" sentinel.
var validColors = map[string]struct{}{
"red": {},
"orange": {},
"yellow": {},
"green": {},
"teal": {},
"blue": {},
"purple": {},
"pink": {},
}
// markPatch is the request body for all three mutating mark endpoints.
@@ -70,12 +75,12 @@ func (p *markPatch) apply(m *Mark) bool {
return m.Rating != nil || (m.Color != nil && *m.Color != "")
}
// allMarksJSON renders the entire `marks` table as the wire shape
// allMarksJSON renders the current user's marks as the wire shape
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start.
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) {
var rows []Mark
if err := db.Find(&rows).Error; err != nil {
if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]map[string]any, len(rows))
@@ -87,7 +92,7 @@ func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
marks, err := allMarksJSON(db)
marks, err := allMarksJSON(db, ctxUserName(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -100,7 +105,7 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var m Mark
err := db.Where("photo_uid = ?", uid).First(&m).Error
err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{})
return
@@ -115,18 +120,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
// upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted).
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) {
var m Mark
err := db.Where("photo_uid = ?", uid).First(&m).Error
err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
m.PhotoUID = uid
m.UserName = userName
keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC()
if !keep {
// Drop the row entirely so a re-fetch returns {}.
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
return nil, err
}
return map[string]any{}, nil
@@ -149,7 +154,7 @@ func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := upsert(db, uid, &patch)
out, err := upsert(db, uid, ctxUserName(c), &patch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -178,6 +183,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userName := ctxUserName(c)
applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp.
@@ -186,7 +192,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
if uid == "" {
continue
}
out, err := upsert(tx, uid, &body.Patch)
out, err := upsert(tx, uid, userName, &body.Patch)
if err != nil {
return err
}

141
sidecar/handlers_photos.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handlePhotos proxies PhotoPrism's /api/v1/photos and then post-filters
// the response so only photos under the caller's BasePath are returned.
// This fixes the review/archive tab cross-user leak.
//
// Route: GET /api/sidecar/timeline (behind requireSession)
func handlePhotos(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward the raw query string to PhotoPrism.
query := c.Request.URL.RawQuery
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
return
}
// Decode as a generic array so we can inspect Path without
// committing to a rigid struct (PhotoPrism's photo response
// varies between list/detail/search endpoints).
var photos []map[string]any
if err := json.Unmarshal(resp.Body, &photos); err != nil {
// If it's not an array (e.g. error, single object), pass through.
c.Data(resp.Status, "application/json", resp.Body)
return
}
// If the user has no BasePath (admin/empty), return as-is.
if basePath == "" {
// Forward PhotoPrism's X-Count header for countPhotos().
if count := resp.Header.Get("X-Count"); count != "" {
c.Header("X-Count", count)
}
c.JSON(http.StatusOK, photos)
return
}
prefix := basePath + "/"
// Post-filter by FileName field (originals-relative path).
filtered := make([]map[string]any, 0, len(photos))
for _, ph := range photos {
rawPath, ok := ph["FileName"]
if !ok {
continue
}
pathStr, ok := rawPath.(string)
if !ok {
continue
}
// Match exact basePath or basePath/...
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
filtered = append(filtered, ph)
}
}
// Forward X-Count header adjusted to the filtered count.
c.Header("X-Count", itoa(len(filtered)))
c.JSON(http.StatusOK, filtered)
}
}
// handleNotes pages PhotoPrism's photo list to completion and returns only
// photos carrying a non-empty Caption (mule-image's "Note"), scoped to the
// caller's BasePath. Paging server-side is what makes this correct: the
// client can't tell when the *BasePath-filtered* list is exhausted (a full
// upstream page can filter down to a short — or empty — slice), but here we
// can key the loop off the raw upstream page length.
//
// Route: GET /api/sidecar/notes (behind requireSession)
func handleNotes(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
prefix := basePath + "/"
const pageSize = 1000
out := make([]map[string]any, 0, 64)
seen := make(map[string]struct{})
for offset := 0; ; offset += pageSize {
path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset)
resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
return
}
var photos []map[string]any
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"})
return
}
rawLen := len(photos)
for _, ph := range photos {
// BasePath scope — same rule as handlePhotos.
if basePath != "" {
pathStr, _ := ph["FileName"].(string)
if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) {
continue
}
}
// Non-empty caption only.
caption, _ := ph["Caption"].(string)
if strings.TrimSpace(caption) == "" {
continue
}
// Dedupe by UID — `merged` can still repeat a photo at a page seam.
uid, _ := ph["UID"].(string)
if uid != "" {
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
}
out = append(out, ph)
}
// A short upstream page means PhotoPrism has no more rows.
if rawLen < pageSize {
break
}
}
c.JSON(http.StatusOK, out)
}
}

View File

@@ -20,6 +20,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
@@ -46,6 +47,18 @@ func main() {
// BasePath wired without an admin restart.
startUserBasepathReconciler(cfg)
// Open a second DB handle pointed at PhotoPrism's own schema for
// handlers that need to query auth_users, photos, labels, etc.
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
var ppDb *gorm.DB
if cfg.PpDSN != "" {
if d, err := openDB(cfg.PpDSN); err == nil {
ppDb = d
} else {
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
}
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded
@@ -84,6 +97,24 @@ func main() {
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
}
// User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp))
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
// the /notes view isn't capped to the newest slice.
auth.GET("/notes", handleNotes(pp))
// User-scoped folders — post-filters the folder tree by BasePath
// so the sidebar shows only folders under the user's library root.
auth.GET("/folders", handleFoldersProxy(pp))
}
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"time"
@@ -85,6 +86,44 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
}, nil
}
// ppSessionUser is the subset of PhotoPrism's session response we need.
type ppSessionUser struct {
UserUID string `json:"UID"`
UserName string `json:"Name"`
BasePath string `json:"BasePath"`
}
type ppSessionResponse struct {
User ppSessionUser `json:"user"`
}
// resolveSession validates the token AND returns the authenticated user.
// Returns nil when the token is invalid or the response can't be parsed.
func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionUser {
if token == "" {
return nil
}
r, err := c.call(ctx, http.MethodGet, "/api/v1/session", token, nil)
if err != nil {
slog.Warn("resolveSession: call failed", "err", err)
return nil
}
if !r.OK {
slog.Warn("resolveSession: not OK", "status", r.Status, "body", string(r.Body[:min(len(r.Body), 200)]))
return nil
}
var resp ppSessionResponse
if err := json.Unmarshal(r.Body, &resp); err != nil {
slog.Warn("resolveSession: unmarshal failed", "err", err, "body", string(r.Body[:min(len(r.Body), 200)]))
return nil
}
if resp.User.UserName == "" {
slog.Warn("resolveSession: empty username", "body", string(r.Body[:min(len(r.Body), 200)]))
return nil
}
return &resp.User
}
// validateSession is the cheapest probe that the supplied token is live:
// list one photo. 401 → bad/expired token. We never read the payload.
func (c *ppClient) validateSession(ctx context.Context, token string) bool {

View File

@@ -4,6 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!-- SVG favicon adapts to light/dark via `prefers-color-scheme`
inside the file itself; PNG remains as a fallback for browsers
that don't support SVG icons. Apple touch icon stays PNG
since iOS home-screen icons can't be SVG. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
%sveltekit.head%

View File

@@ -10,6 +10,9 @@ import {
removeFromHeap,
type PpAlbum
} from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import {
@@ -24,8 +27,8 @@ import {
toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { startBulk, doneBulk, failBulk, setDetail } from '$lib/stores/bulkAction.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
/**
* Optional parameters the host passes via `use:gridKeyNav={...}`.
@@ -160,35 +163,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
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) {
@@ -207,34 +181,25 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
target = !(first?.Archived ?? false);
}
// PhotoPrism's photo PUT silently drops the Archived field — the
// only working path is /api/v1/batch/photos/{archive,restore}. The
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
// back, so the toast fired but nothing moved.
const opLabel = target ? 'Archiving' : 'Restoring';
const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
const tid = toast.loading(`${opLabel} ${ids.length}`);
startBulk(`${opLabel}`, ids);
try {
if (target) await batchArchive(ids);
else await batchRestore(ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
return;
}
// Move focus forward before the photos query refetches, so the
// user can keep X-ing through the timeline without their cursor
// snapping back to photo[0]. Walks past every uid we just
// archived/restored — relevant when the cull targets came from a
// multi-selection rather than the single focused tile.
doneBulk(doneLabel, ids);
focusAfter(ids);
// Drop the now-stale selection set. The archived UIDs are about
// to leave the timeline on refetch, but the SvelteSet membership
// keeps the selection ring on them until then — confusing for
// the user and a footgun if they Ctrl-click to add more and end
// up re-archiving the same photos. The BulkActionBar button path
// clears for the same reason; mirror it here.
clearSelection();
invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
toast.success(label);
pushUndo(label, async () => {
void queryClient.invalidateQueries({ queryKey: ['marks'] });
toast.success(doneLabel, { id: tid });
pushUndo(doneLabel, async () => {
if (target) await batchRestore(ids);
else await batchArchive(ids);
invalidatePhotos(ids);
@@ -259,16 +224,21 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return;
const tid = toast.loading(`Deleting ${ids.length}`);
startBulk('Deleting…', ids);
try {
await batchDelete(ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
return;
}
doneBulk(`Deleted ${ids.length}`, ids);
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
toast.success(`Deleted ${ids.length}`);
void queryClient.invalidateQueries({ queryKey: ['marks'] });
toast.success(`Deleted ${ids.length}`, { id: tid });
}
/** Approve cull targets — clears them out of the review pile by
@@ -284,21 +254,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
});
return;
}
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
// Approve moves photos out of the review pile, so the same
// stale-selection trap as archive/delete applies — advance focus
// past the approved set and drop the now-irrelevant selection
// before invalidate refetches the (smaller) view.
const tid = toast.loading(`Keeping ${ids.length}`);
startBulk('Keeping…', ids);
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
onProgress: (_done, _total, completedId) => {
const p = cachedPhoto(completedId);
if (p) setDetail(p.FileName ?? completedId);
}
});
if (errors.length) {
failBulk(ids);
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
id: tid,
description: errors[0].message
});
} else {
doneBulk(`Kept ${ids.length}`, ids);
toast.success(`Kept ${ids.length}`, { id: tid });
}
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
description: errors[0].message
});
return;
}
toast.success(`Kept ${ids.length}`);
}
// ── S chord (add-to-heap) ────────────────────────────────────────────
@@ -324,25 +300,28 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
});
return;
}
const tid = toast.loading(`Adding ${ids.length}${heap.Title}`);
startBulk(`Adding to ${heap.Title}`, ids);
try {
const { added } = await addToHeap(heap.UID, ids);
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] });
// PhotoPrism returns 200 even when nothing was added — distinguish
// "really added N" from "skipped all N" so the toast tells the
// truth.
if (added.length === 0) {
failBulk(ids);
toast.error(`Nothing added to ${heap.Title}`, {
id: tid,
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
});
return;
}
doneBulk(`Added ${added.length}${heap.Title}`, ids);
if (added.length < ids.length) {
toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, {
id: tid,
description: 'The rest were already in this heap.'
});
} else {
toast.success(`Added ${added.length}${heap.Title}`);
toast.success(`Added ${added.length}${heap.Title}`, { id: tid });
}
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added);
@@ -350,7 +329,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
void queryClient.invalidateQueries({ queryKey: ['photos'] });
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
}
}
@@ -498,6 +478,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (meta) {
e.preventDefault();
for (const id of selection.order) selection.ids.add(id);
return;
}
if (shift) return;
// Bare `a` on the EXIF Stripped review tab fires the same
// "Accept date & Keep" flow as the bar button. Mirrors the
// bar's all-targets-have-a-suggestion gate so the shortcut
// can't silently approve photos without a date fix.
if (
filters.section === 'review' &&
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
) {
const ids = cullTargets();
if (ids.length === 0) return;
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return;
const { fileName, path } = photoNameAndDir(p);
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
return;
}
}
e.preventDefault();
void acceptDateAndKeep(ids);
}
return;
case 'x':

View File

@@ -179,7 +179,7 @@
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
aria-label={`Duplicate group · ${group.files.length} copies`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"

View File

@@ -65,7 +65,7 @@
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
: 'Duplicates scan failed'
);
}
});
@@ -91,7 +91,7 @@
<p>
The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies dropped at index time live under
the Cross-folder tab.
the Duplicates tab.
</p>
{/snippet}
</EmptyState>
@@ -105,9 +105,9 @@
</div>
{/if}
<!-- Cross-folder tab ----------------------------------------------- -->
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files the indexer dropped at index time. Found by scanning the
@@ -139,7 +139,7 @@
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">

View File

@@ -40,6 +40,7 @@
<script lang="ts">
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -105,6 +106,37 @@
if (selectedPath !== undefined) return selectedPath === path;
return filters.folderPath === path;
}
// Auto-expand the ancestor chain of the active folder so the
// highlighted row is actually visible after a deep-link navigation
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
// FolderTree instance only owns the openSet entries for the nodes
// rendered at its depth, but since the root instance expands the
// top-level ancestor first, the child instance for that subtree is
// then mounted and runs the same effect — the cascade naturally
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
// picker has its own selectedPath and shouldn't drive the sidebar
// state). Skipped for top-level paths (nothing to expand).
$effect(() => {
if (readonly || !browser) return;
const fp = selectedPath ?? filters.folderPath;
if (!fp || fp === '/' || !fp.includes('/')) return;
untrack(() => {
const parts = fp.split('/');
let changed = false;
for (let i = 1; i < parts.length; i++) {
const ancestor = parts.slice(0, i).join('/');
if (ancestor && !openSet.has(ancestor)) {
openSet.add(ancestor);
changed = true;
}
}
if (changed) {
openSet = new Set(openSet);
persist();
}
});
});
</script>
<ul>

View File

@@ -1,49 +1,8 @@
<!--
Compact status pill that appears in the header while PhotoPrism's
indexer is doing work. Driven by the indexer store, which subscribes
to PhotoPrism's WS channel. Renders nothing when idle so it never
steals header real estate from the user.
The `detail` (current path/file) is exposed via `title` rather than
rendered inline — the pill stays narrow even on slow flashes through
a deep library, and hover surfaces the detail for users who care.
-->
<!-- PhotoPrism indexer status pill. Driven by the indexer store, which
subscribes to PhotoPrism's WS channel. Delegates rendering to StatusPill. -->
<script lang="ts">
import { indexer } from '$lib/stores/indexer.svelte';
import { Loader2 } from 'lucide-svelte';
// PhotoPrism's `fileName` arrives as the full relative path
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
// recognition; the full path stays in the `title` for users who hover.
const basename = $derived.by(() => {
const d = indexer.detail;
if (!d) return '';
const i = d.lastIndexOf('/');
return i >= 0 ? d.slice(i + 1) : d;
});
import StatusPill from './StatusPill.svelte';
</script>
{#if indexer.active || indexer.label}
<div
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
title={indexer.detail ?? indexer.label}
role="status"
aria-live="polite"
>
{#if indexer.active}
<Loader2 class="h-3 w-3 animate-spin text-primary" />
{/if}
<span class="whitespace-nowrap">{indexer.label}</span>
{#if basename}
<!-- Fixed-width slot so the pill stops shrinking/growing as
PhotoPrism rattles through files of different name lengths.
`w-[24ch]` locks the column; `truncate` ellipsises anything
longer. The full path remains in the parent's `title`. -->
<span
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
>
{basename}
</span>
{/if}
</div>
{/if}
<StatusPill active={indexer.active} label={indexer.label} detail={indexer.detail} />

View File

@@ -7,38 +7,35 @@
import { toast } from 'svelte-sonner';
import {
aggregateKeywords,
countPhotos,
createFolder,
createHeap,
deleteFolder,
deleteHeap,
duplicateHeap,
getAllMarks,
getConfig,
heapDownloadUrl,
listFolderCounts,
listFolders,
listGeo,
listHeaps,
logout,
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
triggerDownload,
type AggregatedKeyword,
type CrossFolderScanResult,
type PhotoMarksMap,
type PpAlbum,
type PpClientConfig,
type PpFolder,
type PpGeoCollection
type PpFolder
} from '$lib/services/photoprism';
import {
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import {
listReviewGroups,
type CauseKey,
type ReviewGroup
} from '$lib/services/adapters/review';
import {
filters,
navigateToFolder,
setFolderPath,
setSection,
TAG_CATEGORIES,
@@ -80,107 +77,14 @@
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
// View counts come from PhotoPrism's `/config` response, which carries a
// precomputed counter for every common bucket (all/archived/labels/
// places/…) updated incrementally on every mutation. Cheap to refetch,
// and gives us a stable total — `/photos` only returns per-page row
// counts via `X-Count`, never a total.
//
// The key sits under the `['photos', …]` prefix so it inherits the
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
// across mutations (archive, restore, delete, heap add) — the counter
// map refreshes whenever the photo list does. Marks-derived counts
// (ratings/colors) react through the shared `['marks']` cache.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: isAuthenticated()
}));
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
// to any authenticated session regardless of role — the timeline
// itself IS scoped per-user, but the precomputed counters aren't.
// `isAdminUser` controls the cheap path: an admin without a
// BasePath gets the precomputed totals from /config directly. Every
// other case (non-admin, or admin scoped to a subfolder) goes
// through `countPhotos()` which appends a `path:<base>*` filter so
// the badge matches what the user can actually see.
const isAdminUser = $derived(session.user?.Role === 'admin');
const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
// Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
// admin with `BasePath === ""` gets a no-op clause and the global
// query; everyone else gets a `path:` clause anchored to their
// BasePath so unrelated folders never contribute to the badge.
// Non-admins with no BasePath have nothing they can see, so we
// short-circuit to a query that returns zero (`uid:none`).
function scoped(filter: string): string {
const bp = userBasePath();
if (isAdminUser && bp === '') return filter;
if (!isAdminUser && bp === '') return 'uid:none';
return `${filter} path:"${bp}*"`.trim();
}
function scopedCountQuery(key: string, filter: string) {
return createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
queryFn: () => countPhotos(scoped(filter)),
enabled: isAuthenticated() && wantScoped,
staleTime: 60_000
}));
}
// One query per badge. Admins with no BasePath skip these
// (enabled:false via `wantScoped`) and the configQuery numbers are
// used directly — same chrome as before that fix, no extra
// round-trips.
const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
const reviewCountQuery = scopedCountQuery('review', 'review:true');
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
// Labels is special: `configQuery.count.labels` is the number of distinct
// label categories (PhotoPrism's roll-up), not the number of photos that
// carry a label. The Tags surface wants picture counts everywhere, so we
// always run a `countPhotos('label:*')` query regardless of the admin/
// BasePath shape and never fall back to the category-count.
const labelsCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
queryFn: () => countPhotos(scoped('label:*')),
enabled: isAuthenticated(),
staleTime: 60_000
gcTime: 0
}));
function bucketCount(
key: 'favorites' | 'review' | 'hidden' | 'archived',
query: { data: number | undefined; isPending: boolean }
): number | undefined {
if (wantScoped) {
if (query.isPending) return undefined;
return query.data;
}
// Admin + no BasePath: use the precomputed PhotoPrism counters
// (no extra round-trip).
const c = configQuery.data?.count;
if (!c) return undefined;
return c[key];
}
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
// Duplicates counts for the sidebar badge. Stacks is a cheap
// PhotoPrism query so we always fetch it; cross-folder is an
// O(disk) scan, so the sidebar only *observes* its cache
// (enabled:false) and the duplicates page itself is what populates
// it on first visit. Both share queryKeys with the /duplicates
// view so cache is reused.
// Stacks + cross-folder duplicate caches are warmed here so the
// /duplicates view (and its review tab strip) hits a warm cache. The
// sidebar only observes these — cross-folder is an O(disk) scan, so it
// stays enabled:false and the duplicates page populates it on first visit.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
@@ -194,117 +98,12 @@
staleTime: 5 * 60_000
}));
// Geotagged-photo count for the Map sidebar badge. PhotoPrism's
// `count.places` is the number of distinct *locations* (cities/states),
// not the number of geotagged photos — so the sidebar would disagree
// with the "N geotagged" footer on /map. Sharing the `['geo']` cache
// keeps both numbers in lockstep and is free after /map's first visit.
const geoQuery = createQuery<PpGeoCollection>(() => ({
queryKey: ['geo'],
queryFn: () => listGeo(),
enabled: isAuthenticated(),
staleTime: 5 * 60_000
}));
// Keywords contribution to the Tags badge. Aggregation is heavy
// (1000-photo fan-out), so the sidebar observes the cache populated
// by /tags?tab=keywords rather than triggering its own fetch — same
// lazy pattern as the cross-folder duplicates count above.
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
enabled: false,
staleTime: 5 * 60_000
}));
const ratingsCount = $derived(countRatings(marksQuery.data));
const colorsCount = $derived(countColors(marksQuery.data));
function countRatings(marks: PhotoMarksMap | undefined): number {
if (!marks) return 0;
let n = 0;
for (const m of Object.values(marks)) {
if ((m.rating ?? 0) > 0) n++;
}
return n;
}
function countColors(marks: PhotoMarksMap | undefined): number {
if (!marks) return 0;
let n = 0;
for (const m of Object.values(marks)) {
if (m.color) n++;
}
return n;
}
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
// Per-folder photo counts. PhotoPrism's /folders/originals reports
// FileCount: 0 for every folder, so the sidecar /folders/counts
// endpoint resolves them in one round-trip (see listFolderCounts).
// Key the query off the folder-path list so it refetches when folders
// are added/renamed/deleted, and share the ['photos', …] prefix so it
// invalidates alongside the other photo caches whenever a mutation
// lands.
//
// `countsReady` gates the query until just after the sidebar's first
// paint. Even though the sidecar response is small, the per-folder
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
// blocking it on idle means the folder list paints immediately and
// the count badges fade in instead of holding back the whole tree.
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
let countsReady = $state(false);
if (browser) {
const kick = () => (countsReady = true);
// requestIdleCallback isn't in Safari yet; fall back to a short
// timeout so the deferral is still bounded.
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
.requestIdleCallback;
if (typeof ric === 'function') ric(kick);
else setTimeout(kick, 200);
}
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
queryFn: () => listFolderCounts(folderPaths),
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
staleTime: 60_000
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root entry shows "the user's library" — for admins without a
// BasePath that's still the whole library, served cheaply from
// /api/v1/config's `count.all`. For any user with a non-empty
// BasePath the precomputed total is wrong (it's library-wide), so we
// ask the sidecar for a recursive count rooted at the user's
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
// resolves to the BasePath itself, and the sidecar fan-out recurses.
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'root-count', userBasePath()],
queryFn: () => listFolderCounts(['']),
enabled: isAuthenticated() && userBasePath() !== '',
staleTime: 60_000
}));
const rootCount = $derived(
userBasePath() === ''
? isAdminUser
? (configQuery.data?.count?.all ?? 0)
: 0
: (scopedRootCountQuery.data?.[''] ?? 0)
);
// Favorites / Review / Hidden / Archive nav entries use these
// derived values rather than peeking at configQuery directly so the
// scoped path is invisible to the views[]/manageViews[] declarations.
const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
const labelsBadge = $derived<number | undefined>(
labelsCountQuery.isPending ? undefined : labelsCountQuery.data
);
// Gates admin-only entry points lower in the sidebar.
const isAdminUser = $derived(session.user?.Role === 'admin');
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
@@ -388,6 +187,53 @@
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
}
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
// row in Manage can expose the same set of tabs the /review page shows
// (cause groups + duplicates panels). Defaults to collapsed.
const REVIEW_OPEN_KEY = 'mule_review_expanded';
let reviewExpanded = $state(loadReviewExpanded());
function loadReviewExpanded(): boolean {
if (!browser) return false;
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
}
function toggleReview() {
reviewExpanded = !reviewExpanded;
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
}
// Cause-tab list is dynamic (only buckets with hits show up on /review),
// so the sidebar mirrors that by reusing the same query. Gated on
// `reviewExpanded` to avoid paying the /photos round-trip for users who
// never expand the section; the queryKey is shared with the /review page
// so visiting that route warms the cache for free.
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'],
queryFn: listReviewGroups,
enabled: isAuthenticated() && reviewExpanded,
staleTime: 30_000
}));
type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
// Stacks + Duplicates are always present on the /review tab strip
// regardless of count (the cross-folder scan is lazy from its own
// panel), so they tail every cause-tab list the sidebar renders.
// The 'cross-folder' tab id is kept internal/URL-stable; the label
// the user sees is "Duplicates".
const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([
...(reviewGroupsQuery.data ?? []).map((g) => ({
id: g.cause as ReviewTabId,
label: g.meta.title
})),
{ id: 'stacks', label: 'Stacks' },
{ id: 'cross-folder', label: 'Duplicates' }
]);
const reviewActive = $derived(page.url.pathname === '/review');
function isReviewTabActive(id: ReviewTabId): boolean {
if (!reviewActive) return false;
return page.url.searchParams.get('tab') === id;
}
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels',
keywords: 'Keywords',
@@ -396,24 +242,6 @@
ratings: 'Ratings'
};
function tagCategoryCount(cat: TagCategory): number | undefined {
// Labels reads PhotoPrism's pre-computed distinct-label counter
// (`/api/v1/config` → count.labels), not the photo-count from
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
// whose indexer hadn't surfaced labelled photos yet, leaving the
// badge silently empty; the precomputed counter is always present
// and reads as "how many labels you can pick from", matching the
// Keywords sub-row's distinct-count semantics.
if (cat === 'labels') return configQuery.data?.count?.labels;
if (cat === 'keywords') return keywordsQuery.data?.length;
// People follows the same distinct-count semantics as Labels —
// `/api/v1/config.count.people` is the number of named subjects PP
// has clustered, surfaced eagerly without a separate /subjects fetch.
if (cat === 'people') return configQuery.data?.count?.people;
if (cat === 'ratings') return ratingsCount;
return colorsCount;
}
function isTagCategoryActive(cat: TagCategory): boolean {
return page.url.pathname.startsWith(`/tags/${cat}`);
}
@@ -518,14 +346,7 @@
}
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 });
await navigateToFolder(folderPath);
}
function onCreateHeap() {
@@ -559,14 +380,12 @@
//
// `getCount` is a getter (not a snapshot) so the badge reads the latest
// derived value on every render — the arrays themselves are constant.
// `count.all` already excludes archived/review/hidden (PhotoPrism's
// "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. Map uses the shared `['geo']`
// cache so its badge matches /map's "N geotagged" footer exactly —
// `count.places` would have shown distinct locations instead.
// Review rolls in the duplicates tabs hosted under /review — stacks
// always contributes; cross-folder only contributes once its tab has
// been opened (the scan is lazy, not eager from the sidebar).
// Map and Tags intentionally render without a count badge; the count
// columns inside the TagsBrowserSidebar are the canonical surface for
// per-tag totals. Review rolls in the duplicates tabs hosted under
// /review — stacks always contributes; cross-folder only contributes
// once its tab has been opened (the scan is lazy, not eager from the
// sidebar).
type ViewItem =
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
@@ -576,43 +395,25 @@
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
// Map's `geoQuery` already returns the GeoJSON the user is
// permitted to see (PhotoPrism's /geo applies the session ACL),
// so the badge is per-user-correct without extra scoping.
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
// section/route ViewItem shape.
// section/route ViewItem shape. Notes lives under that expandable
// alongside the tag categories.
];
// Total badge for the "Tags" header row. Rolls up labels + keywords +
// people + ratings + colors. Labels flows through countPhotos (scoped);
// keywords/people/ratings/colors are library-wide and only contribute
// when we're in admin-without-BasePath mode (their sources don't scope).
const tagsTotal = $derived.by<number | undefined>(() => {
if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0;
const people = configQuery.data?.count?.people ?? 0;
return labelsBadge + keywords + people + ratingsCount + colorsCount;
});
const manageViews: ViewItem[] = [
{
kind: 'route',
href: '/review',
label: 'Review',
getCount: () => {
if (reviewBadge === undefined) return undefined;
// The two duplicates queries are library-wide; only admins
// without a BasePath roll them into the Review badge.
if (wantScoped) return reviewBadge;
return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
function isNotesActive(): boolean {
return page.url.pathname === '/notes';
}
},
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
// Review is rendered separately below as a pure expandable toggle
// (mirroring Tags — no /review landing entry from the sidebar,
// navigation only via subitems, with Hidden tucked in alongside the
// tab subitems). This list carries the flat Manage entries that
// follow it.
const manageViews: ViewItem[] = [
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
];
function isRouteActive(href: string): boolean {
@@ -733,11 +534,6 @@
peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge is the most visually
prominent element on the row and was previously a dead zone.
-->
<button
type="button"
class="flex min-w-0 flex-1 items-center pl-1 text-left"
@@ -745,15 +541,6 @@
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
>
<span class="truncate">{rootLabel}</span>
{#if configQuery.data}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
</button>
<!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted
@@ -773,6 +560,8 @@
</div>
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if foldersQuery.isError}
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />
{:else if !hasSubfolders}
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
{:else if rootExpanded}
@@ -789,7 +578,6 @@
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
counts={folderCounts}
/>
{/if}
</div>
@@ -836,16 +624,9 @@
class="flex min-w-0 flex-1 items-center pl-6 text-left"
onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
title={heap.Title}
>
<span class="truncate">{heap.Title}</span>
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
</button>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions">
@@ -905,9 +686,10 @@
{@render viewRow(v)}
{/each}
<!--
Tags expandable. Whole row is a toggle (chevron + label + badge);
there is no landing page at /tags — selecting a sub-category is the
only way into a real view.
Tags expandable. Whole row is a toggle (chevron + label); there is
no landing page at /tags — selecting a sub-category is the only way
into a real view. Counts intentionally live in the TagsBrowserSidebar
(secondary sidebar) so this row stays a pure navigator.
-->
<button
type="button"
@@ -924,19 +706,28 @@
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
{#if tagsTotal !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
>
{tagsTotal}
</span>
{/if}
</span>
</button>
{#if tagsExpanded}
<!--
Notes lives alongside the tag categories — same indent and row
chrome — but routes to /notes rather than /tags/*. Tucked at
the top of the expandable so it's the first thing the user
sees when opening Tags.
-->
{@const notesActive = isNotesActive()}
<a
href="/notes"
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={notesActive}
class:text-primary-foreground={notesActive}
class:hover:bg-primary={notesActive}
style="padding-left: 36px;"
>
<span class="truncate">Notes</span>
</a>
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
{@const count = tagCategoryCount(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -948,15 +739,6 @@
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</a>
{/each}
{/if}
@@ -971,6 +753,62 @@
Manage
</span>
</div>
<!--
Review expandable. Mirrors the Tags affordance — pure toggle
with no landing page; the only way into a tab is to expand and
pick a subitem. Cause buckets are dynamic (only buckets with
hits show up); Stacks/Cross-folder are always present.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleReview}
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
aria-expanded={reviewExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
>
{reviewExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Review</span>
</span>
</button>
{#if reviewExpanded}
{#each reviewTabs as t (t.id)}
{@const active = isReviewTabActive(t.id)}
<a
href={`/review?tab=${t.id}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
>
<span class="truncate">{t.label}</span>
</a>
{/each}
<!--
Hidden lives under Review since it's the resting place for
photos dismissed during review. Section-nav (not a ?tab=),
so it's a button that flips filters.section like the flat
Manage entries — just with the subitem indent.
-->
{@const hiddenActive = isActive('hidden')}
<button
type="button"
class="flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={hiddenActive}
class:text-primary-foreground={hiddenActive}
class:hover:bg-primary={hiddenActive}
style="padding-left: 36px;"
onclick={() => navigateTo('hidden')}
>
<span class="truncate">Hidden</span>
</button>
{/if}
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}

View File

@@ -0,0 +1,46 @@
<!--
Generic status pill used in the header for both the PhotoPrism indexer
and bulk-action progress. Renders nothing when idle so it never steals
header real estate.
`detail` is a full path or filename; only the basename is shown inline
(fixed-width slot to stop the pill from resizing on every file). The
full string is exposed via `title` for hover.
-->
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
interface Props {
active: boolean;
label: string;
detail?: string;
}
let { active, label, detail }: Props = $props();
const basename = $derived.by(() => {
if (!detail) return '';
const i = detail.lastIndexOf('/');
return i >= 0 ? detail.slice(i + 1) : detail;
});
</script>
{#if active || label}
<div
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
title={detail ?? label}
role="status"
aria-live="polite"
>
{#if active}
<Loader2 class="h-3 w-3 animate-spin text-primary" />
{/if}
<span class="whitespace-nowrap">{label}</span>
{#if basename}
<span
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
>
{basename}
</span>
{/if}
</div>
{/if}

View File

@@ -22,7 +22,7 @@
setFocused,
toggle
} from '$lib/stores/selection.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
const qc = useQueryClient();
@@ -30,22 +30,22 @@
* any time, plenty to cover normal arrow-skim without bloating. */
const WINDOW = 50;
function lookup(uid: string): string | null {
function lookup(uid: string): PpPhoto | null {
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
if (direct) return primaryFile(direct).Hash ?? null;
if (direct) return direct;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
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 primaryFile(hit).Hash ?? null;
if (hit) return hit;
}
}
return null;
@@ -58,6 +58,7 @@
interface Tile {
uid: string;
hash: string | null;
video: boolean;
idx: number;
}
const slice = $derived.by<Tile[]>(() => {
@@ -66,7 +67,13 @@
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
const out: Tile[] = [];
for (let i = lo; i < hi; i++) {
out.push({ uid: order[i], hash: lookup(order[i]), idx: i });
const photo = lookup(order[i]);
out.push({
uid: order[i],
hash: photo ? (primaryFile(photo).Hash ?? null) : null,
video: photo ? isVideo(photo) : false,
idx: i
});
}
return out;
});
@@ -148,12 +155,19 @@
alt=""
loading="lazy"
decoding="async"
fetchpriority="low"
class="h-full w-full object-cover"
/>
{/if}
{#if isSelected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if tile.video}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/each}
</div>

View File

@@ -33,6 +33,7 @@
import PreviewCarousel from './PreviewCarousel.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import { InlineLoader } from '$lib/components/feedback';
const focusedUid = $derived(selection.focused);
@@ -133,7 +134,11 @@
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
</Dialog.Description>
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
<!-- Top row: preview pane (fills) + sidebar (fixed width).
BulkActionBar lives inside the main column — same shape as the
timeline (+page.svelte) so the bar stays bounded by the
column's width and doesn't stretch under the metadata
sidebar. -->
<div class="flex min-h-0 flex-1">
<div class="relative flex min-w-0 flex-1 flex-col">
<button
@@ -148,19 +153,24 @@
<div class="flex min-h-0 flex-1">
<PreviewPane uid={focusedUid} order={selection.order} />
</div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
</div>
{#if focusedPhotoQuery.data}
<!-- Sidebar stays mounted across photo changes so the preview
pane doesn't reflow on arrow-skim; contents swap between
the metadata panel and a small loader the same way the
timeline's right-aside does. -->
<aside
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
>
{#if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
</aside>
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/if}
</aside>
</div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
<!-- Bottom filmstrip across selection.order. -->
<PreviewCarousel />
</Dialog.Content>

View File

@@ -9,10 +9,11 @@
throw away. Until the timer fires, the poster image stands in.
-->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
@@ -27,6 +28,8 @@
}
let { uid, order, showChevrons = true }: Props = $props();
const qc = useQueryClient();
const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''],
queryFn: () => getPhoto(uid as string),
@@ -35,6 +38,48 @@
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
* cache without firing a fetch, so we can resolve adjacent hashes for
* prefetching without making the prefetch itself trigger more work. */
function lookupCached(target: string): PpPhoto | null {
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
if (direct) return direct;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === target);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === target);
if (hit) return hit;
}
}
return null;
}
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
// arrow-skim feels instant. We `new Image()` rather than `<link
// rel=preload>` because the URLs are runtime-derived and a throwaway
// Image() reuses the browser's HTTP cache the same way.
$effect(() => {
if (!uid || currentIndex < 0) return;
for (const offset of [-1, 1, -2, 2]) {
const idx = currentIndex + offset;
if (idx < 0 || idx >= order.length) continue;
const photo = lookupCached(order[idx]);
if (!photo) continue;
const hash = primaryFile(photo).Hash;
if (!hash) continue;
const img = new Image();
img.src = thumbUrl(hash, 'fit_1280');
}
});
const VIDEO_LOAD_DELAY_MS = 250;
let armedUid = $state<string | null>(null);
@@ -86,9 +131,8 @@
</button>
{/if}
{#if isVideo(photoQuery.data)}
{#if isVideo(photoQuery.data) && armedUid === uid}
{@const vf = videoFile(photoQuery.data)}
{#if armedUid === uid}
{#key vf.Hash}
<VideoPlayer
src={videoUrl(vf.Hash)}
@@ -97,17 +141,33 @@
/>
{/key}
{:else}
{@const altText =
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
src={thumbSrc(pf.Hash, view.thumbnailSize)}
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/>
{/if}
{:else}
<img
src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
alt={altText}
fetchpriority="high"
decoding="async"
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
{/if}
{/if}

View File

@@ -1,246 +1,61 @@
<!--
One review-queue cause group rendered as a card. Mirrors StackGroupCard's
chrome (focusable container, ResizeObserver column tracking, keyboard
nav) but the per-tile semantics differ:
One review-queue cause group, rendered as a thin wrapper around
PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
main, BulkActionBar below, RightSidebar/BulkMetadataSidebar on the
right) — this component just adds the per-group header + suggestion
row above the grid, then delegates tiles to PhotoGrid so selection,
keyboard nav, and previews work the same way they do everywhere else.
- Click a tile → emits `select` so the parent can open the metadata
sidebar. Shift-click toggles bulk-select instead of opening.
- Header has `Approve all` + `Archive all` for the whole group.
- Suggestion line above the grid spotlights the likely-correct bulk
action with an inline button (per the plan).
- Keyboard: arrows move the focused tile; `S` approves the focused
tile, `A` archives it, `Enter` opens detail, `Esc` blurs.
The Low Resolution tab opts in to PhotoTile's dimension badge so the
user can spot under-2-MP photos without opening each tile.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
approvePhoto,
batchArchive
} from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import {
deriveCauses,
type ReviewGroup
} from '$lib/services/adapters/review';
import CauseBadges from './CauseBadges.svelte';
import { batchArchive } from '$lib/services/photoprism';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import { type ReviewGroup } from '$lib/services/adapters/review';
interface Props {
group: ReviewGroup;
autoFocus?: boolean;
/** Parent emits when the user picks a tile to inspect (Enter or
* plain click). Parent owns the RightSidebar mount. */
onSelect?: (photo: PpPhoto) => void;
}
let { group, autoFocus = false, onSelect }: Props = $props();
let { group }: Props = $props();
const qc = useQueryClient();
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let focusedIdx = $state(0);
let cols = $state(1);
let busy = $state(false);
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Match StackGroupCard's column-tracking trick so arrow Up/Down jump
// by row width.
$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 moveFocus(delta: number) {
if (group.photos.length === 0) return;
focusedIdx = Math.min(
Math.max(0, focusedIdx + delta),
group.photos.length - 1
);
}
function dims(p: PpPhoto): string {
const f = primaryFile(p);
const w = p.Width ?? f.Width;
const h = p.Height ?? f.Height;
if (!w || !h) return '';
return `${w}×${h}`;
}
function thumb(p: PpPhoto): string {
// list endpoint puts the hash on the photo itself; primaryFile is
// the fallback for detail responses.
const h = p.Hash ?? primaryFile(p).Hash;
return h ? thumbUrl(h, 'tile_500') : '';
}
async function approveOne(p: PpPhoto) {
if (busy) return;
busy = true;
try {
await approvePhoto(p.UID);
toast.success('Approved');
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Approve failed');
} finally {
busy = false;
}
}
async function archiveOne(p: PpPhoto) {
if (busy) return;
busy = true;
try {
await batchArchive([p.UID]);
toast.success('Archived');
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
} finally {
busy = false;
}
}
async function approveAll() {
if (busy || group.photos.length === 0) return;
if (!confirm(`Approve all ${group.photos.length} photos in "${group.meta.title}"?`)) return;
busy = true;
const total = group.photos.length;
let done = 0;
const toastId = toast.loading(`Approving 0 / ${total}…`);
try {
// PhotoPrism has no batch-approve, so fan out one-at-a-time.
// A small concurrency cap keeps the server responsive without
// stalling for very large groups.
const QUEUE = 4;
const uids = group.photos.map((p) => p.UID);
let idx = 0;
async function worker() {
while (idx < uids.length) {
const my = idx++;
try {
await approvePhoto(uids[my]);
} catch {
// Carry on — partial success is better than abort.
}
done++;
toast.loading(`Approving ${done} / ${total}…`, { id: toastId });
}
}
await Promise.all(Array.from({ length: Math.min(QUEUE, uids.length) }, worker));
toast.success(`Approved ${done} / ${total}`, { id: toastId });
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Approve all failed', {
id: toastId
});
} finally {
busy = false;
}
}
async function archiveAll() {
if (busy || group.photos.length === 0) return;
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
return;
const uids = group.photos.map((p) => p.UID);
const tid = toast.loading(`Archiving ${uids.length}…`);
busy = true;
startBulk(`Archiving…`, uids);
try {
await batchArchive(group.photos.map((p) => p.UID));
toast.success(`Archived ${group.photos.length}`);
await batchArchive(uids);
doneBulk(`Archived ${uids.length}`, uids);
toast.success(`Archived ${uids.length}`, { id: tid });
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
void qc.invalidateQueries({ queryKey: ['marks'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive all failed');
failBulk(uids);
toast.error(err instanceof Error ? err.message : 'Archive all failed', { id: tid });
} finally {
busy = false;
}
}
function onKeydown(e: KeyboardEvent) {
if (busy) return;
const p = group.photos[focusedIdx];
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
moveFocus(-1);
return;
case 'ArrowRight':
e.preventDefault();
moveFocus(1);
return;
case 'ArrowUp':
e.preventDefault();
moveFocus(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveFocus(cols);
return;
case 'Enter':
e.preventDefault();
if (p) onSelect?.(p);
return;
case 's':
case 'S':
e.preventDefault();
if (p) void approveOne(p);
return;
case 'a':
case 'A':
e.preventDefault();
if (p) void archiveOne(p);
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
}
}
function runSuggestion() {
if (group.meta.suggestedAction === 'approve') void approveAll();
else if (group.meta.suggestedAction === 'archive') void archiveAll();
// 'manual' suggestion has no button — the suggestion line is text-only.
// Only 'archive' suggestions are reachable through this button now —
// the page's BulkActionBar handles per-photo / multi-select Keep.
if (group.meta.suggestedAction === 'archive') void archiveAll();
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cause group ${group.meta.title} with ${group.photos.length} photos`}
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"
>
<div class="space-y-2">
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-foreground">
@@ -248,128 +63,30 @@
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.photos.length === 0}
onclick={approveAll}
title="Approve every photo in this group"
>
Approve all
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.photos.length === 0}
onclick={archiveAll}
title="Archive every photo in this group"
>
Archive all
</button>
</div>
</header>
<!-- Suggestion line — sits above the grid, surfaces the likely-correct
bulk action with an inline trigger. 'manual' causes get no
inline button; the user has to use the header bulk bar instead. -->
<!-- Suggestion line — surfaces the likely-correct bulk action. Only
'archive' renders a quick-button; 'manual' is text-only and
'approve' is unused today. Per-photo Keep / Archive comes from the
page's BulkActionBar (review section) once the user selects. -->
<div
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
>
<span>{group.meta.suggestion}</span>
{#if group.meta.suggestedAction !== 'manual'}
{#if group.meta.suggestedAction === 'archive'}
<button
type="button"
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={runSuggestion}
>
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
Archive all
</button>
{/if}
</div>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.photos as photo, i (photo.UID)}
{@const causes = deriveCauses(photo)}
{@const isFocused = i === focusedIdx}
<!-- Tile is a <div> with role=button so the inner per-tile
action buttons aren't nested inside another <button> (which
is invalid HTML and trips a11y linters). -->
<div
role="button"
tabindex="-1"
aria-label={`${photo.FileName ?? photo.Name ?? photo.UID} — press Enter to inspect`}
onclick={() => onSelect?.(photo)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect?.(photo);
}
}}
class:ring-2={isFocused}
class:ring-blue-500={isFocused}
class:ring-offset-2={isFocused}
class:ring-offset-background={isFocused}
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumb(photo)}
alt={photo.FileName ?? photo.Name ?? ''}
loading="lazy"
class="h-full w-full object-cover"
<PhotoGrid
photos={group.photos}
dimensionBadge={group.cause === 'low_resolution'}
/>
{#if dims(photo)}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>
{dims(photo)}
</span>
{/if}
<!-- Per-tile hover actions: stop propagation so a click
here doesn't also open the sidebar. -->
<div
class="absolute bottom-1.5 right-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
type="button"
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
onclick={(e) => {
e.stopPropagation();
void approveOne(photo);
}}
title="Approve (S)"
>
Approve
</button>
<button
type="button"
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
onclick={(e) => {
e.stopPropagation();
void archiveOne(photo);
}}
title="Archive (A)"
>
Archive
</button>
</div>
</div>
<div class="space-y-1 px-2 py-1.5">
<CauseBadges {causes} />
<div
class="truncate text-[10px] leading-tight text-muted-foreground"
title={photo.FileName ?? photo.Name ?? ''}
>
{photo.FileName ?? photo.Name ?? ''}
</div>
</div>
</div>
{/each}
</div>
</div>

View File

@@ -17,7 +17,9 @@
type PhotoMarksMap,
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { patchTargets } from '$lib/services/bulk';
import { patchTargets, invalidateFacets } from '$lib/services/bulk';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
const qc = useQueryClient();
@@ -34,10 +36,19 @@
let colorDraft = $state<string | null>(null);
let busy = $state(false);
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
// `label` drives the per-photo tile overlay (pending → done / error) via the
// shared bulkAction store, so metadata applies show the same progress state
// as the archive/keep actions in BulkActionBar.
async function withBusy<T>(fn: () => Promise<T>, label?: string): Promise<T> {
busy = true;
if (label) startBulk(`${label}…`, ids);
try {
return await fn();
const result = await fn();
if (label) doneBulk(label, ids);
return result;
} catch (e) {
if (label) failBulk(ids);
throw e;
} finally {
busy = false;
}
@@ -46,13 +57,16 @@
async function applyNote() {
if (busy) return;
const value = noteDraft;
await withBusy(() =>
const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
{ Caption: value, CaptionSrc: 'manual' },
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
label,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
)
),
label
);
noteDraft = '';
}
@@ -63,27 +77,28 @@
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
// fill the local timezone field downstream.
const iso = `${dateDraft}T00:00:00Z`;
await withBusy(() =>
const label = `Date → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
`Date → ${ids.length}`,
label,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
: ({ TakenSrc: '' } as UpdatePhotoBody)
)
),
label
);
dateDraft = '';
}
async function applyMarks(patch: PhotoMark, label: string) {
if (busy) return;
const tid = toast.loading(`${label}…`);
startBulk(`${label}…`, ids);
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) {
@@ -97,9 +112,14 @@
});
try {
await bulkSetMarks(ids, patch);
toast.success(`${label} · ${ids.length}`);
doneBulk(label, ids);
// Refresh the Colors / Ratings facet panels — they sit on
// `['marks']` + `['photos','marks-pool']`, not the optimistic write above.
invalidateFacets();
toast.success(`${label} · ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid });
void qc.invalidateQueries({ queryKey: ['marks'] });
}
});
@@ -119,19 +139,14 @@
colorDraft = null;
}
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
async function applyKeyword() {
if (busy) return;
const kw = keywordDraft.trim().replace(/,/g, '');
if (!kw) return;
keywordDraft = '';
await withBusy(() =>
const label = `Tagged "${kw}" → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
(p) => {
@@ -143,11 +158,12 @@
const next = [...cur, kw].join(', ');
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
},
`Tagged "${kw}" → ${ids.length}`,
label,
(p) => ({
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
)
),
label
);
}
@@ -270,16 +286,18 @@
</button>
</section>
<!-- Color label — same pattern as Score. -->
<!-- Colors — 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">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)}
{@const picked = colorDraft === c.key}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
class:ring-foreground={colorDraft === c.key}
class:ring-transparent={colorDraft !== c.key}
class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
? c.bg
: 'bg-transparent'}"
aria-pressed={picked}
disabled={busy}
onclick={() => (colorDraft = c.key)}
title={`Pick ${c.title}`}

View File

@@ -1,109 +0,0 @@
<!--
One horizontal strip of related-photo thumbnails for the metadata
sidebar. Used three times on the /review sidebar (folder / camera /
year). Self-fetches via the PhotoPrism DSL so each strip stays
independent.
The header is clickable: it navigates back to the timeline with the
same DSL applied as a `?q=` param, so the user can drill into the
full result set if they want to. Strips with zero hits collapse
silently — no header, no whitespace.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { listPhotos } from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
import { Loader2 } from 'lucide-svelte';
interface Props {
title: string;
/** PhotoPrism DSL fragment, e.g. `path:"2024/lyon"` or `year:2024`. */
q: string;
/** Cap on tiles rendered in the strip. Defaults to a small set
* that fits one row in a typical sidebar width. */
limit?: number;
/** UID to filter out — usually the photo whose sidebar this strip
* is on, so the user doesn't see itself in its own "related"
* list. */
excludeUid?: string;
}
let { title, q, limit = 12, excludeUid }: Props = $props();
const stripQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['related', q, limit],
queryFn: () => listPhotos({ q, count: limit + 1, order: 'newest' }),
// Strips are cheap to refetch; the data behind them changes
// rarely, but a stale-while-revalidate window keeps the sidebar
// snappy when the user clicks through similar photos.
staleTime: 60_000,
enabled: q.length > 0
}));
const photos = $derived(
(stripQuery.data ?? []).filter((p) => p.UID !== excludeUid).slice(0, limit)
);
function openTimeline() {
// Same `?q=` param the timeline already accepts (see filters store)
// — clicking the strip header pivots the main timeline into the
// same filtered scope so the user can browse the full set.
const params = new URLSearchParams({ q });
void goto(`/?${params.toString()}`, { keepFocus: true });
}
function openOne(uid: string) {
// Focus the picked photo so the sidebar re-renders against it.
// Useful for the "decide these together" workflow without leaving
// the review page.
setFocused(uid);
}
</script>
{#if stripQuery.isPending}
<div
class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2 class="h-2.5 w-2.5 animate-spin" aria-hidden="true" />
<span>Loading {title.toLowerCase()}</span>
</div>
{:else if stripQuery.isError}
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
{null}
{:else if photos.length > 0}
<div class="space-y-1">
<button
type="button"
class="flex w-full items-baseline justify-between text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground"
onclick={openTimeline}
title={`Open the timeline filtered by ${q}`}
>
<span>{title}</span>
<span class="text-muted-foreground/70">({photos.length}+)</span>
</button>
<div class="flex gap-1 overflow-x-auto">
{#each photos as p (p.UID)}
<button
type="button"
class="h-12 w-12 shrink-0 overflow-hidden rounded border border-border bg-secondary hover:border-primary"
onclick={() => openOne(p.UID)}
title={p.FileName ?? p.Name ?? p.UID}
>
{#if p.Hash}
<img
src={thumbUrl(p.Hash, 'tile_100')}
alt=""
loading="lazy"
class="h-full w-full object-cover"
/>
{/if}
</button>
{/each}
</div>
</div>
{/if}

View File

@@ -6,17 +6,20 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
Aperture,
ArrowUpRight,
Calendar,
ExternalLink,
File,
Folder,
HardDrive,
ImageIcon,
Loader2,
Map as MapIcon,
MapPin,
Star,
Tag,
@@ -34,19 +37,20 @@
type PhotoMarksMap,
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { invalidateFacets } from '$lib/services/bulk';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import RelatedStrip from './RelatedStrip.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder } from '$lib/stores/filters.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props {
/** When true, append related-photo strips (folder/camera/year) below
* Keywords. Used by the /review route; left off on the timeline. */
showRelated?: boolean;
photo: PpPhoto;
}
let { photo, showRelated = false }: Props = $props();
let { photo }: Props = $props();
const qc = useQueryClient();
@@ -89,12 +93,20 @@
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
return updatePhoto(fresh, patch);
},
onMutate: () => {
startBulk('Saving…', [photo.UID]);
},
onSuccess: (data) => {
qc.setQueryData(['photo', data.UID], data);
void qc.invalidateQueries({ queryKey: ['photos'] });
// Keep the keyword / notes facet panels in sync with the edit.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Save failed')
onError: (err) => {
failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
}));
function commit(patch: UpdatePhotoBody) {
@@ -130,6 +142,32 @@
commit({ Caption: caption, CaptionSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the
// row anywhere else would compete with the existing TakenAt.
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
// guess or file mtime), so a per-photo "needs date" heuristic would
// silently hide the suggestion — the tab is the more reliable signal.
const onExifStrippedTab = $derived(
page.url.pathname === '/review' &&
page.url.searchParams.get('tab') === 'stripped_exif'
);
const dateSuggestion = $derived.by(() => {
const { fileName, path } = photoNameAndDir(photo);
return suggestDateFromPath({
fileName,
originalName: photo.OriginalName,
path
});
});
const showDateSuggestion = $derived(
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
);
function applyDateSuggestion() {
if (!dateSuggestion) return;
takenAt = dateSuggestion.iso;
commitTakenAt();
}
function commitTakenAt() {
if (!takenAt) return;
if (!isValidISODate(takenAt)) {
@@ -206,12 +244,17 @@
if (!optimistic.rating) delete optimistic.rating;
if (!optimistic.color) delete optimistic.color;
patchMarksCache(photo.UID, optimistic);
startBulk('Saving…', [photo.UID]);
try {
const saved = await setMark(photo.UID, patch);
patchMarksCache(photo.UID, saved);
// Refresh the Colors / Ratings facet panels off the sidecar truth.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
} catch (err) {
// Rollback on failure.
patchMarksCache(photo.UID, prev);
failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
}
@@ -225,29 +268,20 @@
}
/** Click-to-toggle: clicking the current color clears it; clicking a
* different swatch swaps. Same four-swatch palette as mule-image. */
* different swatch swaps. */
function setColor(next: string) {
const value = currentColor === next ? '' : next;
if (value === currentColor) return;
void applyMark({ color: value });
}
// Tooltips follow the Lightroom culling convention so the swatches
// read as actions, not just colors. Red = reject, Yellow = pick,
// Green = keep, Orange = review-later.
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo));
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived(
pf.Size
@@ -266,12 +300,6 @@
? 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 ?? '';
@@ -333,19 +361,61 @@
/>
</div>
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
padding on filename / date so the read-only text starts at the
same x-offset as the editable rows above — otherwise spans
hug the icon while inputs sit 4px in. -->
{#if dirPath}
<div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}>
{dirPath}/
<!-- Date suggestion derived from filename / folder signals. Only
shown on the EXIF Stripped review tab; amber styling marks
it as unconfirmed. `(estimated day)` hint appears when the
day was synthesised because only Y-M was available — same
row, just so the user knows that part is fabricated. Apply
writes the value into the date input above and commits as
a manual TakenAt edit. -->
{#if showDateSuggestion && dateSuggestion}
<div
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<Folder class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
{#if dateSuggestion.source === 'path-ym-default-day'}
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
{/if}
</span>
<button
type="button"
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
onclick={applyDateSuggestion}
>
Apply
</button>
</div>
{/if}
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
mirrors the input padding on filename / date so the read-only
text starts at the same x-offset as the editable rows above —
otherwise spans hug the icon while inputs sit 4px in. Root-level
files render as `/` so the row never disappears. The arrow-up-
right icon navigates to the timeline filtered by this folder
with the photo pre-focused. -->
<div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
{folderLabel}
</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
void navigateToFolder(dirPath || '/', {
focusUid: photo.UID,
focusTakenAt: photo.TakenAt ?? null
})}
title="Open folder in timeline"
aria-label="Open folder in timeline"
>
<ArrowUpRight class="h-3 w-3" />
</button>
</div>
<!-- Dimensions -->
<div class="flex items-center gap-2">
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -362,45 +432,41 @@
</span>
</div>
<!-- Location -->
<!-- Location (read-only label + open-on-map icon). The arrow-up-
right icon flies the in-app map to the photo's coordinates at
zoom 17 (close enough for the photo's marker to be its own,
out of any cluster). Hidden when the photo has no
coordinates. -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if mapsHref}
<a
href={mapsHref}
target="_blank"
rel="noopener"
{#if photo.Lat && photo.Lng}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
title="Open in OpenStreetMap"
onclick={() =>
void goto(
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
)}
title="Open on map"
aria-label="Open on map"
>
<ExternalLink class="h-3 w-3" />
</a>
<MapIcon class="h-3 w-3" />
</button>
{/if}
</div>
</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>
<!-- Tags — score, color label, keywords, and auto-labels grouped under
one collapsible section. Score + color are stored on the mule-
sidecar (PhotoPrism's PUT can't persist them); keywords live on
Details; auto-labels come from PhotoPrism's TF classifier and are
read-only. Open by default since these are the culling marks the
user reaches for first. -->
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
under one collapsible section. Note (PhotoPrism's Caption field,
labelled here to match mule-image's nomenclature) sits at the top
of the group since it's the most-edited per-photo field. Score +
color are stored on the mule-sidecar (PhotoPrism's PUT can't
persist them); keywords live on Details; auto-labels come from
PhotoPrism's TF classifier and are read-only. Open by default
since these are the culling marks the user reaches for first. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)}
@@ -414,6 +480,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
@@ -434,14 +511,16 @@
</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">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)}
{@const picked = currentColor === c.key}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
class:ring-foreground={currentColor === c.key}
class:ring-transparent={currentColor !== c.key}
class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
? c.bg
: 'bg-transparent'}"
aria-pressed={picked}
onclick={() => setColor(c.key)}
title={c.title}
aria-label={`Color ${c.key}`}
@@ -511,35 +590,6 @@
</div>
</details>
<!-- Related strips (only the /review route opts in). The three
scopes match the three decisions the user usually makes here:
"did all these come from the same shoot?" (folder), "same
camera, EXIF-stripped together?" (camera), "right year?"
(year). Strips with zero hits collapse silently. -->
{#if showRelated}
<div class="space-y-2 border-t border-border pt-2">
<RelatedStrip
title="Same folder"
q={`path:"${photo.Path ?? ''}"`}
excludeUid={photo.UID}
/>
{#if photo.CameraID && photo.CameraID !== 1}
<RelatedStrip
title="Same camera"
q={`camera:${photo.CameraID}`}
excludeUid={photo.UID}
/>
{/if}
{#if photo.Year}
<RelatedStrip
title="Same year"
q={`year:${photo.Year}`}
excludeUid={photo.UID}
/>
{/if}
</div>
{/if}
<!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr

View File

@@ -4,7 +4,7 @@
aggregateKeywords,
getAllMarks,
listLabels,
listPhotos,
listPhotosByUids,
listSubjects,
type AggregatedKeyword,
type PhotoMarksMap,
@@ -72,12 +72,17 @@
}));
// Same marks-pool query the drill page uses — colors/ratings need a
// representative photo per bucket for the count rollup. Cheap once
// cached; the drill page kicks the same key.
// representative photo per bucket for the count rollup. Resolved from the
// marked UIDs (complete set, any age) so the rollup counts every marked
// photo, not just those in the newest-N timeline slice.
const markedUids = $derived(Object.keys(marksQuery.data ?? {}));
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors')
queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
queryFn: () => listPhotosByUids(markedUids),
enabled:
isAuthenticated() &&
(category === 'ratings' || category === 'colors') &&
markedUids.length > 0
}));
// PhotoPrism returns labels in arbitrary order; sort by photo count
@@ -465,7 +470,8 @@
onclick={() => pickColor(swatch.key)}
title={swatch.title}
>
<span class="h-3 w-3 shrink-0 rounded-full {swatch.bg}"></span>
<span class="h-3 w-3 shrink-0 rounded-full border-2 bg-transparent {swatch.border}"
></span>
<span class="min-w-0 flex-1 truncate">{swatch.title}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
@@ -12,6 +13,9 @@
type PpAlbum
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import {
clearBulkToFirst,
clearSelection,
@@ -22,6 +26,14 @@
import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import {
startBulk,
setDetail,
doneBulk,
failBulk,
markRemoved,
clearRemoved
} from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
@@ -50,11 +62,50 @@
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
const isBulk = $derived(selection.ids.size > 0);
// Filename for the single-focus label. Re-derives whenever
// selection.focused flips — cachedPhoto reads from the same query
// cache that drives the visible tiles, so the name resolves on the
// same tick the tile renders.
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
const focusedName = $derived(
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
// that's where path-derived dates are the most useful fix. Outside the
// tab the button stays hidden even if a selected photo would otherwise
// have a path-parseable date, to keep other tabs uncluttered.
const onExifStrippedTab = $derived(
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
);
// Surface the button only when EVERY targeted photo has a derivable
// suggestion — otherwise clicking it would silently approve some
// photos without a date fix, which contradicts the verb. A uid not in
// any cache also counts as "no suggestion" so we don't promise
// something we can't verify.
const allHaveSuggestion = $derived.by(() => {
if (!onExifStrippedTab) return false;
const ids =
selection.ids.size > 0
? Array.from(selection.ids)
: selection.focused
? [selection.focused]
: [];
if (ids.length === 0) return false;
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return false;
const { fileName, path } = photoNameAndDir(p);
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
return false;
}
}
return true;
});
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived;
@@ -70,55 +121,93 @@
setFocused(null);
}
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
interface BulkConfig {
ids: string[];
label: string;
doneLabel: string;
}
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
busy = true;
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
try {
return await fn();
const result = await fn();
if (bulk) {
doneBulk(bulk.doneLabel, bulk.ids);
await delay(1000);
}
return result;
} catch (e) {
if (bulk) failBulk(bulk.ids);
throw e;
} finally {
busy = false;
void qc.invalidateQueries({ queryKey: ['photos'] });
const settled = Promise.all([
qc.invalidateQueries({ queryKey: ['photos'] }),
qc.invalidateQueries({ queryKey: ['marks'] }),
qc.invalidateQueries({ queryKey: ['review-groups'] })
]);
// Clear the optimistic-removal overlay only once the refetch has
// landed, so tiles never flash back in before the fresh (archived-
// filtered) page replaces the old one.
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
}
}
async function onApprove() {
const ids = snapshotIds();
if (ids.length === 0) return;
const tid = toast.loading(`Keeping ${ids.length}…`);
await withBusy(async () => {
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
// no /unapprove route. We fan out per-photo because there's no
// batch endpoint either. Errors are tallied rather than aborting
// the loop so a single bad UID doesn't block the rest.
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Kept ${ids.length}`);
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
onProgress: (_done, _total, completedId) => {
const p = cachedPhoto(completedId);
setDetail(p?.FileName ?? completedId);
}
});
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
} else {
toast.success(`Kept ${ids.length}`, { id: tid });
}
// Approved photos leave the review section — hide them immediately.
markRemoved(ids);
focusAfter(ids);
clearSelection();
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
}
async function onAcceptDateAndKeep() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(() => acceptDateAndKeep(ids), {
ids,
label: 'Updating',
doneLabel: `Updated ${ids.length}`
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
const tid = toast.loading(`Archiving ${ids.length}…`);
await withBusy(async () => {
try {
await batchArchive(ids);
markRemoved(ids);
pushUndo(`Archived ${ids.length}`, async () => {
await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
// Advance focus to the photo immediately after the archived
// set before the multi-selection is dropped — lets the user
// keep stepping through the timeline with X.
focusAfter(ids);
clearSelection();
toast.success(`Archived ${ids.length}`);
toast.success(`Archived ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
}
});
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
}
async function onDelete() {
@@ -129,61 +218,68 @@
? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return;
const tid = toast.loading(`Deleting ${ids.length}…`);
await withBusy(async () => {
try {
await batchDelete(ids);
markRemoved(ids);
focusAfter(ids);
clearSelection();
toast.success(`Deleted ${ids.length}`);
toast.success(`Deleted ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
}
});
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
}
async function onRestore() {
const ids = snapshotIds();
if (ids.length === 0) return;
const tid = toast.loading(`Restoring ${ids.length}…`);
await withBusy(async () => {
try {
await batchRestore(ids);
markRemoved(ids);
pushUndo(`Restored ${ids.length}`, async () => {
await batchArchive(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
focusAfter(ids);
clearSelection();
toast.success(`Restored ${ids.length}`);
toast.success(`Restored ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Restore failed');
toast.error(err instanceof Error ? err.message : 'Restore failed', { id: tid });
}
});
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
}
async function onAddToHeap(heap: PpAlbum) {
const ids = snapshotIds();
if (!ids.length) return;
heapPickerOpen = false;
const tid = toast.loading(`Adding ${ids.length}${heap.Title}…`);
startBulk(`Adding to ${heap.Title}…`, ids);
await withBusy(async () => {
try {
const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] });
// PhotoPrism returns 200 even when nothing was added (UIDs
// already present or unknown to the index) — surface the
// real delta so the user isn't fooled by a green toast over
// a no-op.
if (added.length === 0) {
failBulk(ids);
toast.error(`Nothing added to ${heap.Title}`, {
id: tid,
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
});
return;
}
doneBulk(`Added ${added.length}${heap.Title}`, ids);
await delay(400);
if (added.length < ids.length) {
toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, {
id: tid,
description: 'The rest were already in this heap.'
});
} else {
toast.success(`Added ${added.length}${heap.Title}`);
toast.success(`Added ${added.length}${heap.Title}`, { id: tid });
}
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added);
@@ -191,7 +287,8 @@
});
clearSelection();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
}
});
}
@@ -207,13 +304,19 @@
<div
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
>
<span class="shrink-0 text-[11px] font-medium text-foreground">
{#if isBulk}
<span class="shrink-0 text-[11px] font-medium text-foreground">
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
{:else}
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
<span
class="min-w-0 truncate text-[11px] font-medium text-foreground"
title={focusedName || undefined}
>
{focusedName || 'photo'}
</span>
{/if}
<!--
`overflow-x-auto` would clip the heap-picker dropdown — CSS
@@ -231,16 +334,31 @@
the archive section. Everything else (heap, restore)
is hidden so the choice reads as decisive. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={busy}
onclick={onApprove}
title="Keep — accept into timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
</button>
{#if allHaveSuggestion}
<!-- Visible only when every selected photo has a path-
derivable date. Clicking applies each photo's
suggestion then approves it; mirrored by the bare
`a` shortcut in gridKeyNav. -->
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
disabled={busy}
onclick={onAcceptDateAndKeep}
title="Accept the date suggested from the file/folder path, then keep"
>
📅 Accept date & Keep
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
@@ -255,13 +373,13 @@
photo is already archived; the destructive styling
reinforces the irreversibility. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Keep — restore to timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
@@ -275,13 +393,13 @@
{:else}
<div class="relative">
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 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 text-[9px] font-medium text-muted-foreground"
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
>S&nbsp;N</kbd
>
</button>
@@ -320,7 +438,7 @@
{/if}
</div>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
@@ -330,7 +448,7 @@
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>

View File

@@ -0,0 +1,81 @@
<!--
Notes-view flat grid — same skeleton as PhotoGrid, but each cell pairs
a photo with its note hint via NotesPhotoTile. Kept as a sibling
component (rather than threading a `note` slot through PhotoGrid) so
the Notes-view chrome stays out of the shared timeline/tags codepath.
The grid carries `data-photo-grid` and each tile (rendered inside
PhotoTile) carries `data-tile`+`data-uid` — same contract the
gridKeyNav action and shared selection helpers expect, so arrow-key
nav, range select, and bulk action bar work for free.
-->
<script lang="ts">
import { untrack } from 'svelte';
import {
isSelected,
selection,
setAnchor,
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview, view } from '$lib/stores/view.svelte';
import type { PhotoWithNote } from '$lib/services/photoprism';
import NotesPhotoTile from './NotesPhotoTile.svelte';
interface Props {
items: PhotoWithNote[];
columns?: string;
}
let { items, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
const order = $derived(items.map((it) => it.photo.UID));
$effect(() => {
setOrder(order);
untrack(() => {
if (order.length === 0) {
setFocused(null);
selection.ids.clear();
return;
}
const cur = selection.focused;
if (cur && order.includes(cur)) return;
setFocused(order[0]);
setAnchor(order[0]);
selection.ids.clear();
});
});
function onClick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
function onDblclick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
openPreview();
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each items as item (item.photo.UID)}
{@const sel = isSelected(item.photo.UID) || selection.focused === item.photo.UID}
<NotesPhotoTile
photo={item.photo}
note={item.note}
selected={sel}
onClick={(e) => onClick(e, item.photo.UID)}
onDblclick={(e) => onDblclick(e, item.photo.UID)}
/>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<!--
PhotoTile + footer card showing a hint of the photo's note. Only the
Notes view (/notes) uses this — every other surface keeps the bare
PhotoTile, so the note-card chrome doesn't leak into the timeline or
tag drill-ins.
Composition: square PhotoTile on top, presentational note strip
underneath. Clicks/dblclicks land on PhotoTile's own <button data-tile>
so selection/keyboard/preview behave exactly like every other tile.
-->
<script lang="ts">
import type { PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
interface Props {
photo: PpPhoto;
selected: boolean;
note: string;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
}
let { photo, selected, note, onClick, onDblclick }: Props = $props();
</script>
<div class="flex h-full w-full flex-col">
<div class="aspect-square">
<PhotoTile {photo} {selected} {onClick} {onDblclick} />
</div>
<div
class="line-clamp-2 rounded-b-md border border-t-0 border-border bg-card px-2 py-1.5 text-[11px] leading-snug text-muted-foreground"
title={note}
>
{note}
</div>
</div>

View File

@@ -31,8 +31,12 @@
* `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */
columns?: string;
/** Forwarded to every PhotoTile. The Low Resolution review tab
* opts in so users can spot pixel dimensions without opening
* each tile. */
dimensionBadge?: boolean;
}
let { photos, columns }: Props = $props();
let { photos, columns, dimensionBadge = false }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
@@ -91,6 +95,7 @@
<PhotoTile
{photo}
selected={sel}
{dimensionBadge}
onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)}
/>

View File

@@ -16,17 +16,32 @@
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
import { fade } from "svelte/transition";
import { Loader2, Check, X } from "lucide-svelte";
interface Props {
photo: PpPhoto;
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
/** Opt-in `WxH` overlay in the top-right corner. Used by the Low
* Resolution review tab so the user can spot-check pixel dimensions
* without opening each tile. Default off so other surfaces stay
* uncluttered. */
dimensionBadge?: boolean;
}
let { photo, selected, onClick, onDblclick }: Props = $props();
let { photo, selected, onClick, onDblclick, dimensionBadge = false }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo));
const dims = $derived.by(() => {
if (!dimensionBadge) return '';
const f = primaryFile(photo);
const w = photo.Width ?? f.Width;
const h = photo.Height ?? f.Height;
return w && h ? `${w}×${h}` : '';
});
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
@@ -39,7 +54,7 @@
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function onMouseEnter() {
if (!video || selected) return;
if (!video || selected || bulkState) return;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
hoverPlaying = true;
@@ -61,6 +76,7 @@
const tilePx = $derived(view.thumbnailSize);
const src1x = $derived(thumbSrc(hash, tilePx));
const srcset = $derived(thumbSrcSet(hash, tilePx));
const bulkState = $derived(bulkPhotoStates.get(photo.UID));
</script>
<!--
@@ -138,11 +154,34 @@
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if bulkState === 'pending'}
<div class="pointer-events-none absolute inset-0 bg-black/50"></div>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<Loader2 class="h-5 w-5 animate-spin text-white/80 drop-shadow" />
</div>
{:else if bulkState === 'done'}
<div
transition:fade={{ duration: 200 }}
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-emerald-500/70"
>
<Check class="h-7 w-7 text-white drop-shadow-md" />
</div>
{:else if bulkState === 'error'}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
<X class="h-7 w-7 text-white drop-shadow-md" />
</div>
{/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}
{#if dims}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>{dims}</span
>
{/if}
</button>
</div>

View File

@@ -22,7 +22,7 @@
<div
aria-hidden="true"
class="grid gap-2"
class="mt-2 grid gap-2"
style="grid-template-columns: {tracks};"
>
{#each Array.from({ length: count }) as _, i (i)}

View File

@@ -13,7 +13,7 @@ export interface BatchResult<T> {
export interface BatchOptions {
concurrency?: number;
onProgress?: (done: number, total: number) => void;
onProgress?: (done: number, total: number, completedId: string) => void;
}
export async function batchEdit<T>(
@@ -38,7 +38,7 @@ export async function batchEdit<T>(
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
} finally {
done++;
opts.onProgress?.(done, ids.length);
opts.onProgress?.(done, ids.length, id);
}
}
}

View File

@@ -32,6 +32,31 @@ export function invalidatePhotos(uids: string[]): void {
}
}
/**
* Refresh the sidebar facet sections after a metadata mutation. The Colors /
* Ratings panels read `['marks']` + `['photos','marks-pool']`; Notes reads
* `['photos','with-notes']`; keywords / labels / people read their own keys.
* Optimistic cache writes keep the active tile in sync, but the facet panels
* sit on separate queries that otherwise stay stale until their staleTime
* expires — so call this on the success path of any marks/keyword/note apply.
*/
export function invalidateFacets(): void {
void queryClient.invalidateQueries({ queryKey: ['marks'] });
void queryClient.invalidateQueries({ queryKey: ['photos', 'marks-pool'] });
void queryClient.invalidateQueries({ queryKey: ['photos', 'with-notes'] });
void queryClient.invalidateQueries({ queryKey: ['photos', 'keywords'] });
void queryClient.invalidateQueries({ queryKey: ['labels'] });
void queryClient.invalidateQueries({ queryKey: ['subjects'] });
}
export function invalidateAllPhotoCaches(): void {
void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] });
void queryClient.invalidateQueries({ queryKey: ['labels'] });
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
}
/**
* 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
@@ -57,20 +82,22 @@ export async function patchTargets(
)
: null;
const tid = toast.loading(`${label} · ${ids.length}`);
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);
invalidateFacets();
if (errors.length) {
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
} else {
toast.success(`${label} · ${ids.length}`);
toast.success(`${label} · ${ids.length}`, { id: tid });
}
if (inverses) {

View File

@@ -14,10 +14,49 @@
import { toast } from 'svelte-sonner';
import { batchEdit } from './batch';
import { invalidatePhotos } from './bulk';
import { approvePhoto, batchArchive, batchRestore } from './photoprism';
import {
approvePhoto,
batchArchive,
batchRestore,
buildTakenAtPatch,
updatePhoto
} from './photoprism';
import { queryClient } from '$lib/queryClient';
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir, type PpPhoto } from '$lib/types/photoprism';
/** Walk every cache that might hold a photo's metadata — timeline list
* (flat or infinite), review-groups bucket, per-photo detail — without
* forcing a refetch. Returns undefined when the uid hasn't been seen.
* Shared by callers that need to look up photo state by uid from
* outside a component (gridKeyNav, photoActions). */
export 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 pg of pages) {
const hit = pg?.find?.((p) => p.UID === uid);
if (hit) return hit;
}
}
const review = queryClient.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
if (review) {
for (const group of review) {
const hit = group.photos?.find((p) => p.UID === uid);
if (hit) return hit;
}
}
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
}
/**
* Dismiss photos out of the review queue by bumping their quality
@@ -27,21 +66,59 @@ import { push as pushUndo } from '$lib/stores/undo.svelte';
*/
export async function dismissPhotos(uids: string[]): Promise<void> {
if (uids.length === 0) return;
const tid = toast.loading(`Dismissing ${uids.length}`);
const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id));
// Advance focus past the dismissed set before the timeline refetches
// so the cursor doesn't snap back to photo[0]; clear the now-stale
// selection ring for the same reason.
focusAfter(uids);
clearSelection();
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
if (errors.length) {
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
id: tid,
description: errors[0].message
});
return;
}
toast.success(`Dismissed ${uids.length}`);
toast.success(`Dismissed ${uids.length}`, { id: tid });
}
/**
* Walk the selected uids, applying each photo's path-derived date
* suggestion (when one exists) before approving it. UIDs without a
* suggestion fall through to a plain approve. Used by the EXIF Stripped
* review tab — the `📅 Accept date & Keep` button and the bare `a`
* keyboard shortcut both route here so wording / focus / toast
* behaviour stay in lockstep.
*/
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
if (uids.length === 0) return;
const tid = toast.loading(`Updating & keeping ${uids.length}`);
const { updated, errors } = await batchEdit(uids, async (id) => {
const p = cachedPhoto(id);
if (p) {
const { fileName, path } = photoNameAndDir(p);
const guess = suggestDateFromPath({
fileName,
originalName: p.OriginalName,
path
});
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
}
await approvePhoto(id);
return id;
});
focusAfter(uids);
clearSelection();
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
id: tid,
description: errors[0].message
});
return;
}
toast.success(`Kept ${uids.length}`, { id: tid });
}
/**
@@ -49,10 +126,11 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
*/
export async function archivePhotos(uids: string[]): Promise<void> {
if (uids.length === 0) return;
const tid = toast.loading(`Archiving ${uids.length}`);
try {
await batchArchive(uids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
return;
}
pushUndo(`Archived ${uids.length}`, async () => {
@@ -64,5 +142,5 @@ export async function archivePhotos(uids: string[]): Promise<void> {
clearSelection();
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
toast.success(`Archived ${uids.length}`);
toast.success(`Archived ${uids.length}`, { id: tid });
}

View File

@@ -28,6 +28,14 @@ const http: AxiosInstance = axios.create({
headers: { 'Content-Type': 'application/json' }
});
/** Axios instance for sidecar endpoints — no baseURL prefix so paths
* like `/api/sidecar/timeline` resolve directly through Caddy's
* `/api/sidecar/*` rule instead of becoming `/api/v1/api/sidecar/*`. */
const sidecar: AxiosInstance = axios.create({
baseURL: '',
headers: { 'Content-Type': 'application/json' }
});
http.interceptors.request.use((config) => {
if (session.accessToken) {
config.headers = config.headers ?? {};
@@ -36,6 +44,14 @@ http.interceptors.request.use((config) => {
return config;
});
sidecar.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) => {
@@ -51,6 +67,20 @@ http.interceptors.response.use(
}
);
sidecar.interceptors.response.use(
(r) => r,
(err: AxiosError) => {
if (err.response?.status === 401 && browser) {
clearSession();
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> {
@@ -144,7 +174,7 @@ export interface ListPhotosParams {
}
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
const { data } = await http.get<PpPhoto[]>('/photos', {
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: {
count: 60,
offset: 0,
@@ -156,6 +186,115 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
return data;
}
/**
* Resolve photos for an explicit set of UIDs. Used by the Colors / Ratings
* facets, whose member set comes from the mule-sidecar marks store and is NOT
* bounded to the newest N photos — a marked photo anywhere in the library must
* resolve. Fetches per-UID (concurrency-bounded) via the same `/photos/:uid`
* endpoint the metadata panel uses, so it can't drift from PhotoPrism's search
* DSL. Missing UIDs (deleted since marked) are skipped.
*/
export async function listPhotosByUids(uids: string[]): Promise<PpPhoto[]> {
if (uids.length === 0) return [];
const out: PpPhoto[] = [];
const concurrency = 8;
for (let i = 0; i < uids.length; i += concurrency) {
const slice = uids.slice(i, i + concurrency);
const fetched = await Promise.all(slice.map((uid) => getPhoto(uid).catch(() => null)));
for (const p of fetched) if (p) out.push(p);
}
return out;
}
/**
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
* older photos preceded by `after` newer ones, merged newest-first.
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
* neighbours can be loaded without paging through the whole filter.
*
* Used by the timeline's deep-link focus mode: an in-app navigation
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
* date for page 0, and the target photo lands ~`afterCount` tiles
* down with `~beforeCount` older neighbours below it.
*
* Subsequent infinite-scroll pages use plain `listPhotos` with the
* standard offset cursor — the anchor mode only matters for page 0.
*/
export interface AroundParams {
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
q?: string;
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
takenAt: string;
/** How many photos newer than the anchor to fetch. */
afterCount?: number;
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
beforeCount?: number;
merged?: boolean;
}
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
const afterCount = p.afterCount ?? 30;
const beforeCount = p.beforeCount ?? 90;
const baseQ = p.q?.trim() ?? '';
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
// the `before:` half (so the target tile is in the merged result).
const anchorDate = new Date(p.takenAt);
if (Number.isNaN(anchorDate.getTime())) {
// Date parse failed — fall back to a plain newest-first page.
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
}
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
// up/down by a day so the anchor's own day is included in the
// `before:` half — the bounds are inclusive day boundaries, so a
// timestamp-precision anchor lands inside the `[beforeBound,
// afterBound]` window.
function ymd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
const dayMs = 86_400_000;
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
const newerQ = `${baseQ} after:${afterBound}`.trim();
const olderQ = `${baseQ} before:${beforeBound}`.trim();
const [newerOldestFirst, older] = await Promise.all([
listPhotos({
q: newerQ,
count: afterCount,
order: 'oldest',
merged: p.merged ?? true
}),
listPhotos({
q: olderQ,
count: beforeCount,
order: 'newest',
merged: p.merged ?? true
})
]);
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
// to match the standard timeline order, then concat the older window.
// Dedupe by UID in case the anchor itself shows up in both halves.
const merged: PpPhoto[] = [];
const seen = new Set<string>();
for (const p of newerOldestFirst.slice().reverse()) {
if (!seen.has(p.UID)) {
merged.push(p);
seen.add(p.UID);
}
}
for (const p of older) {
if (!seen.has(p.UID)) {
merged.push(p);
seen.add(p.UID);
}
}
return merged;
}
/**
* Count photos matching a DSL query, scoped to whatever the caller's
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
@@ -169,10 +308,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
* the signed-in user actually sees, not the global library aggregate
* exposed by `/config.count`.
*/
export async function countPhotos(q: string): Promise<number> {
const resp = await http.get('/photos', {
params: { count: 10000, offset: 0, merged: false, q }
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
const merged = opts.merged ?? false;
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 10000, offset: 0, merged, q }
});
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
// `Files[]` entry), regardless of `merged`. With `merged: true` the
// response body is one entry per logical photo — so the body length
// is the canonical photo count when callers need to match what the
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
const header = resp.headers['x-count'];
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
return Number.isFinite(n) ? n : 0;
@@ -358,11 +504,14 @@ export interface PpFolder {
* BasePath is empty (today's admin default) this is a no-op.
*/
export async function listFolders(): Promise<PpFolder[]> {
const { data } = await http.get<{ folders?: PpFolder[] }>(
'/folders/originals',
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } }
);
const bp = userBasePath();
// Sidecar already filters by BasePath; the frontend still applies the
// filter + path rewrite as a safety net for admin (bp="") and for any
// folders that might have slipped through.
const folders = data.folders ?? [];
if (bp === '') return folders;
return folders
@@ -400,7 +549,7 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
// on the way out, then re-key the response back to user-relative on
// the way in so callers' map keys line up with their input array.
const serverPaths = paths.map((p) => toOriginalsPath(p));
const data = (await sidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
const data = (await callSidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
string,
number
>;
@@ -478,6 +627,32 @@ export interface AggregatedKeyword {
sampleHash: string;
}
/**
* Photos carrying a non-empty user note. mule-image's "Note" is
* PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which
* is a top-level scalar — present on the list response, so a single
* round-trip is enough.
*/
export interface PhotoWithNote {
photo: PpPhoto;
note: string;
}
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
// The sidecar pages PhotoPrism to completion server-side and returns only
// captioned, BasePath-scoped photos — paging client-side would stop early
// because each page is BasePath-filtered before we see it (a full upstream
// page can arrive short), silently hiding notes past the first slice.
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/notes');
const out: PhotoWithNote[] = [];
for (const p of data) {
const note = p.Caption?.trim();
if (!note) continue;
out.push({ photo: p, note });
}
return out;
}
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>();
@@ -512,6 +687,35 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
}
async function hasPhotosMatching(q: string): Promise<boolean> {
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 1, offset: 0, q }
});
return Array.isArray(resp.data) && resp.data.length > 0;
}
async function filterByUserPhotos<T>(
items: T[],
queryFor: (item: T) => string
): Promise<T[]> {
if (userBasePath() === '') return items;
const CONCURRENCY = 8;
const out: T[] = [];
for (let i = 0; i < items.length; i += CONCURRENCY) {
const batch = items.slice(i, i + CONCURRENCY);
const checks = await Promise.all(
batch.map(async (item) => ({
item,
has: await hasPhotosMatching(queryFor(item))
}))
);
for (const { item, has } of checks) {
if (has) out.push(item);
}
}
return out;
}
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
@@ -520,9 +724,16 @@ export async function listLabels(): Promise<PpLabel[]> {
// 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 }
//
// Uses the sidecar proxy (/api/sidecar/labels) instead of PhotoPrism's
// /api/v1/labels so PhotoCount reflects only photos under the user's
// BasePath. The sidecar proxies the request through to PP then
// post-filters each label's count.
const { data } = await sidecar.get<PpLabel[]>('/api/sidecar/labels', {
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
});
// Sidecar already filters to the user's scope and sets correct counts +
// thumbs in one DB query — no need to probe each label individually.
return data;
}
@@ -547,7 +758,7 @@ export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', {
params: { count: 1000, order: 'count' }
});
return data ?? [];
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
}
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
@@ -685,7 +896,7 @@ export interface RenameResult {
newRelPath: string;
}
async function sidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
async function callSidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
const res = await fetch(`/api/sidecar${urlPath}`, {
method,
headers: {
@@ -703,20 +914,20 @@ async function sidecar(method: string, urlPath: string, body?: unknown): Promise
}
export async function createFolder(relPath: string): Promise<{ path: string }> {
return sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
return callSidecar('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`, {
return callSidecar('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<{
return callSidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
path: string;
}>;
}
@@ -749,7 +960,7 @@ export interface CrossFolderScanResult {
}
export async function scanCrossFolderDuplicates(): Promise<CrossFolderScanResult> {
return sidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
return callSidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
}
export interface ArchiveDuplicatesResult {
@@ -760,7 +971,7 @@ export interface ArchiveDuplicatesResult {
export async function archiveDuplicatePaths(
paths: string[]
): Promise<ArchiveDuplicatesResult> {
return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
}
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
@@ -792,7 +1003,7 @@ export async function convertHeap(
uid: string,
body: HeapConvertBody
): Promise<HeapConvertResult> {
return sidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
}
// ── Photo marks (rating + color) ─────────────────────────────────────────────
@@ -808,19 +1019,19 @@ export interface PhotoMark {
export type PhotoMarksMap = Record<string, PhotoMark>;
export async function getAllMarks(): Promise<PhotoMarksMap> {
const data = await sidecar('GET', '/photos/marks');
const data = await callSidecar('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>;
return callSidecar('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<{
return callSidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
count: number;
marks: PhotoMarksMap;
}>;

View File

@@ -0,0 +1,82 @@
/**
* Bulk-action status, written by BulkActionBar and read by the header
* StatusPill and individual PhotoTile overlays.
*
* State lifecycle:
* startBulk → pill spins, all target tiles go "pending"
* setDetail → pill shows the filename currently being processed (fan-out ops)
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
* failBulk → tiles flash red, auto-clears after 2 s
*/
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface BulkActionState {
active: boolean;
label: string;
detail?: string;
}
export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile
// reliably re-runs when the entry flips — the plain-Map proxy form wasn't
// re-rendering the timeline tiles' overlay.
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error'>();
/**
* UIDs hidden from the timeline grid the instant a removing action (archive /
* delete / restore) succeeds, so tiles vanish without waiting on the ~1s
* server-reconcile refetch. The caller clears each id once the refetch lands.
* This is a pure UI overlay — it never touches the query cache, so it can't
* corrupt the facet/drill caches the way a direct cache eviction did.
*/
export const removedIds = $state(new SvelteSet<string>());
export function markRemoved(ids: string[]): void {
for (const id of ids) removedIds.add(id);
}
export function clearRemoved(ids: string[]): void {
for (const id of ids) removedIds.delete(id);
}
let doneTimer: ReturnType<typeof setTimeout> | null = null;
export function startBulk(label: string, ids: string[]): void {
if (doneTimer !== null) {
clearTimeout(doneTimer);
doneTimer = null;
}
bulkPhotoStates.clear();
for (const id of ids) bulkPhotoStates.set(id, 'pending');
bulkAction.active = true;
bulkAction.label = label;
bulkAction.detail = undefined;
}
export function setDetail(path: string): void {
bulkAction.detail = path;
}
export function doneBulk(label: string, ids: string[]): void {
for (const id of ids) bulkPhotoStates.set(id, 'done');
bulkAction.active = false;
bulkAction.label = label;
bulkAction.detail = undefined;
if (doneTimer !== null) clearTimeout(doneTimer);
doneTimer = setTimeout(() => {
bulkAction.label = '';
bulkPhotoStates.clear();
doneTimer = null;
}, 3000);
}
export function failBulk(ids: string[]): void {
for (const id of ids) bulkPhotoStates.set(id, 'error');
bulkAction.active = false;
bulkAction.label = '';
bulkAction.detail = undefined;
setTimeout(() => {
for (const id of ids) bulkPhotoStates.delete(id);
}, 2000);
}

View File

@@ -86,6 +86,59 @@ export function setTagFilter(
filters.tagValue = value;
}
/**
* One-shot focus hand-off between an in-app navigation source (e.g. the
* RightSidebar's Folder open icon) and the timeline. We deliberately
* avoid encoding this in the URL — the store→URL effect on the
* timeline strips any param that `filtersToUrlParams` doesn't emit, so
* a `?focus=` param wouldn't survive the round-trip. A module-level
* stash that's consumed once on the next pageCount=1 landing is the
* simplest contract: not shareable, not replayed on refresh, but
* matches the "deep-link click" UX we want.
*
* `takenAt` (when known) lets the timeline anchor its first-page
* query around the target's date via PhotoPrism's `before:`/`after:`
* DSL — so deep-link focus works even for photos that aren't in the
* newest-120 page of the destination filter. Caller passes `null` if
* the date isn't readily available; the timeline can still attempt a
* page-1 match.
*/
export interface PendingFocus {
uid: string;
takenAt: string | null;
}
let pendingFocus: PendingFocus | null = null;
export function setPendingFocus(uid: string, takenAt: string | null = null): void {
pendingFocus = { uid, takenAt };
}
export function consumePendingFocus(): PendingFocus | null {
const v = pendingFocus;
pendingFocus = null;
return v;
}
/**
* Drill into a folder on the timeline. Mirrors the LeftSidebar tree's
* click handler: clear heap/section context so the folder filter
* applies on top of "all photos", then navigate. When `focusUid` is
* provided, the timeline's focus effect consumes the pending-focus
* stash on its first-page landing and pre-selects + scrolls to that
* photo instead of snapping to `photos[0]`. `focusTakenAt` enables
* the anchor-mode query so the photo can be found even when it would
* otherwise be past page 1.
*/
export async function navigateToFolder(
folderPath: string,
opts: { focusUid?: string; focusTakenAt?: string | null } = {}
): Promise<void> {
setSection('all-photos');
setFolderPath(folderPath);
if (opts.focusUid) setPendingFocus(opts.focusUid, opts.focusTakenAt ?? null);
const params = new URLSearchParams();
params.set('folder', folderPath);
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
/**
* Navigate to a tag-category browse URL. Path-segment shape
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's

View File

@@ -213,6 +213,23 @@ export interface PpPhotoLabel {
Label?: { Slug: string; Name: string };
}
/**
* Split a photo into its basename + directory portion using the
* primary file's relative `Name` (`'2024/02/IMG.jpg'` → `{ fileName:
* 'IMG.jpg', path: '2024/02' }`). Falls back to `photo.Path` when the
* primary file's `Name` lacks a directory prefix — that pairs with the
* list-endpoint shape where `Path` is its own field. Shared so date-
* suggestion code paths in RightSidebar / photoActions / gridKeyNav
* derive inputs the same way regardless of which cache shape they
* have on hand (list vs detail).
*/
export function photoNameAndDir(p: PpPhoto): { fileName: string; path: string } {
const full = primaryFile(p).Name ?? '';
const i = full.lastIndexOf('/');
if (i < 0) return { fileName: full, path: p.Path ?? '' };
return { fileName: full.slice(i + 1), path: full.slice(0, i) };
}
/**
* 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

View File

@@ -0,0 +1,221 @@
/**
* Best-effort calendar date guessed from a photo's filename / parent
* folders, with a confidence label so the UI can warn the user when
* the day is fabricated.
*
* Conventions we support (no false-positive risk):
* - Samsung / Android stock 20240226_135421.jpg
* - Google Pixel PXL_20240226_135421.123.jpg
* - WhatsApp IMG-20240226-WA0001.jpg
* - Telegram photo_2024-02-26_13-54-21.jpg
* - macOS screenshot Screen Shot 2024-02-26 at 1.54.21 PM.png
* - Android screenshot Screenshot_20240226-135421.png
* - Manual dot-format 2024.02.26 - title.jpg
* - WeChat mmexport1645900000000.jpeg (13-digit ms)
* - Facebook saves FB_IMG_1583926812.jpg (10-digit s)
* - Path forms 2024/02/26, 2024-02-26, 2024_02_26,
* 2024.02.26, plus Y-M-only 2024/02/
*
* Conventions we deliberately do NOT parse (locale-ambiguous or no
* recoverable signal):
* - DD-MM-YYYY / MM-DD-YYYY 26-02-2024.jpg, 02-26-2024.jpg
* - 2-digit years 24-02-26.jpg
* - Bare sequence numbers IMG_1234.HEIC, DSC_0123.NEF,
* GOPR0123.JPG, DJI_0123.JPG
*
* Used by the EXIF Stripped review tab to surface a date suggestion
* row in the metadata sidebar and to power the "Accept date & Keep"
* bulk action.
*/
import { isValidISODate } from '$lib/services/photoprism';
interface Input {
fileName?: string; // basename, e.g. '20240226_000000_A6D42DF3.jpg'
originalName?: string; // optional second filename signal (PpPhoto.OriginalName)
path?: string; // directory portion, e.g. '2024/02'
}
export interface DateGuess {
iso: string;
confidence: 'high' | 'medium';
source:
| 'filename-agrees-path'
| 'filename-only'
| 'unix-timestamp'
| 'path-ymd'
| 'path-ym-default-day';
}
interface YMD {
y: number;
m: number;
d: number;
}
interface YM {
y: number;
m: number;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function isoOf(ymd: YMD): string {
return `${ymd.y}-${pad2(ymd.m)}-${pad2(ymd.d)}`;
}
function tryYMD(y: number, m: number, d: number): YMD | null {
if (y < 1900 || y > 2100) return null;
if (m < 1 || m > 12) return null;
if (d < 1 || d > 31) return null;
if (!isValidISODate(`${y}-${pad2(m)}-${pad2(d)}`)) return null;
return { y, m, d };
}
function tryYM(y: number, m: number): YM | null {
if (y < 1900 || y > 2100) return null;
if (m < 1 || m > 12) return null;
return { y, m };
}
// `YYYY[sep]MM[sep]DD` for basenames. sep ∈ {nothing, -, _, ., space}. The
// non-digit lookbehind/ahead keeps a leading prefix like `PXL_` and a
// trailing time like `_135421` from polluting the match.
const BASENAME_YMD = /(?<!\d)(\d{4})[-_. ]?(\d{2})[-_. ]?(\d{2})(?!\d)/;
// Path Y-M-D and Y-M. Includes `/` for directory separators and `.` for
// rare dot-organised libraries (`Photos/2024.02/...`).
const PATH_YMD = /(?<!\d)(\d{4})[-_/.](\d{2})[-_/.](\d{2})(?!\d)/;
const PATH_YM = /(?<!\d)(\d{4})[-_/.](\d{2})(?!\d)/;
// 10- or 13-digit Unix epoch, anchored. Years widened to [1990, current+1]
// to dodge accidental matches on phone numbers, hex hashes containing
// digits, etc. — but 10-digit seconds still has to round-trip into a
// plausible calendar year before we trust it.
const BASENAME_EPOCH = /(?<!\d)(\d{10}|\d{13})(?!\d)/;
function parseFilenameYMD(name: string): YMD | null {
const m = name.match(BASENAME_YMD);
if (!m) return null;
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
}
function parseUnixTimestampInName(name: string): YMD | null {
const m = name.match(BASENAME_EPOCH);
if (!m) return null;
const digits = m[1];
const ms = digits.length === 13 ? Number(digits) : Number(digits) * 1000;
if (!Number.isFinite(ms)) return null;
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return null;
const y = d.getUTCFullYear();
if (y < 1990 || y > new Date().getUTCFullYear() + 1) return null;
return tryYMD(y, d.getUTCMonth() + 1, d.getUTCDate());
}
function parsePathYMD(path: string): YMD | null {
const m = path.match(PATH_YMD);
if (!m) return null;
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
}
function parsePathYM(path: string): YM | null {
const m = path.match(PATH_YM);
if (!m) return null;
return tryYM(Number(m[1]), Number(m[2]));
}
function ymdAgreesWithYM(ymd: YMD, ym: YM): boolean {
return ymd.y === ym.y && ymd.m === ym.m;
}
function ymdEqual(a: YMD, b: YMD): boolean {
return a.y === b.y && a.m === b.m && a.d === b.d;
}
/** Pick the filename-derived YMD that best aligns with the path. When
* both `fileName` and `originalName` yield candidates, prefer the one
* that matches the path's year+month; ties fall back to `fileName`. */
function pickFilenameYMD(
fileName: string,
originalName: string,
pathYM: YM | null
): YMD | null {
const candidates: YMD[] = [];
const a = parseFilenameYMD(fileName);
if (a) candidates.push(a);
if (originalName && originalName !== fileName) {
const b = parseFilenameYMD(originalName);
if (b && !candidates.some((c) => ymdEqual(c, b))) candidates.push(b);
}
if (candidates.length === 0) return null;
if (!pathYM) return candidates[0];
const aligned = candidates.find((c) => ymdAgreesWithYM(c, pathYM));
return aligned ?? candidates[0];
}
function pickUnixTimestamp(fileName: string, originalName: string): YMD | null {
return (
parseUnixTimestampInName(fileName) ??
(originalName && originalName !== fileName
? parseUnixTimestampInName(originalName)
: null)
);
}
export function suggestDateFromPath(input: Input): DateGuess | null {
const fileName = (input.fileName ?? '').trim();
const originalName = (input.originalName ?? '').trim();
const path = (input.path ?? '').trim();
const pathYMD = path ? parsePathYMD(path) : null;
const pathYM = path && !pathYMD ? parsePathYM(path) : null;
// 1. Filename Y-M-D corroborated by the path.
const fnYMD = pickFilenameYMD(fileName, originalName, pathYM);
if (fnYMD) {
if (pathYMD && ymdEqual(fnYMD, pathYMD)) {
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
}
if (pathYM && ymdAgreesWithYM(fnYMD, pathYM)) {
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
}
// 2. Filename Y-M-D with no path signal at all.
if (!pathYMD && !pathYM) {
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-only' };
}
// Filename present but disagrees with path → fall through.
}
// 3. Unix epoch in filename, optionally corroborated.
const epoch = pickUnixTimestamp(fileName, originalName);
if (epoch) {
if (!pathYM && !pathYMD) {
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
if (pathYM && ymdAgreesWithYM(epoch, pathYM)) {
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
if (pathYMD && ymdEqual(epoch, pathYMD)) {
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
// disagreement → fall through to path
}
// 4. Path Y-M-D standalone.
if (pathYMD) {
return { iso: isoOf(pathYMD), confidence: 'high', source: 'path-ymd' };
}
// 5. Path Y-M with synthesised day = 01.
if (pathYM) {
const ymd = tryYMD(pathYM.y, pathYM.m, 1);
if (ymd) {
return { iso: isoOf(ymd), confidence: 'medium', source: 'path-ym-default-day' };
}
}
return null;
}

View File

@@ -10,15 +10,29 @@ import type { PhotoMarksMap } from '$lib/services/photoprism';
import type { PpPhoto } from '$lib/types/photoprism';
/**
* Lightroom culling convention: red rejects, orange reviews, yellow
* picks, green keeps. Order here is the order the TagsBrowser renders
* rows in — fixed so the user can build muscle memory.
* Color labels are purely a marking dimension — no semantic meaning is
* attached. Order here is the order the TagsBrowser renders rows in,
* roughly rainbow-then-neutrals so the picker reads naturally.
*
* `bg` and `border` are paired Tailwind classes so a swatch can be
* rendered either filled (e.g. to indicate selection) or as a colored
* outline (the default display). Literal strings keep Tailwind's
* content scanner happy — do not interpolate.
*/
export const COLOR_SWATCHES: readonly { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
export const COLOR_SWATCHES: readonly {
key: string;
bg: string;
border: string;
title: string;
}[] = [
{ key: 'red', bg: 'bg-red-500', border: 'border-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', border: 'border-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', border: 'border-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', border: 'border-green-500', title: 'Green' },
{ key: 'teal', bg: 'bg-teal-500', border: 'border-teal-500', title: 'Teal' },
{ key: 'blue', bg: 'bg-blue-500', border: 'border-blue-500', title: 'Blue' },
{ key: 'purple', bg: 'bg-purple-500', border: 'border-purple-500', title: 'Purple' },
{ key: 'pink', bg: 'bg-pink-500', border: 'border-pink-500', title: 'Pink' }
] as const;
export interface RatingGroup {

View File

@@ -13,7 +13,9 @@
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
import { bulkAction } from '$lib/stores/bulkAction.svelte';
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import StatusPill from '$lib/components/layout/StatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
@@ -72,6 +74,7 @@
<div class="flex h-screen flex-col overflow-hidden">
<AnimatedMule>
<IndexerStatusPill />
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
</AnimatedMule>
<div class="flex min-h-0 flex-1">
{#if !view.leftSidebarCollapsed}

View File

@@ -13,15 +13,18 @@
getPhoto,
listHeaps,
listPhotos,
listPhotosAround,
type PpAlbum,
} from "$lib/services/photoprism";
import {
consumePendingFocus,
filters,
filtersToQ,
filtersToUrlParams,
parseUrlParams,
setSearch,
setSection,
type PendingFocus,
} from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte";
@@ -34,6 +37,7 @@
setFocused,
setOrder,
} from "$lib/stores/selection.svelte";
import { removedIds } from "$lib/stores/bulkAction.svelte";
import {
openPreview,
setRightSidebarWidth,
@@ -137,24 +141,90 @@
// 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;
// Anchor mode: when an in-app deep link stashes a pending focus with a
// TakenAt, the first page is fetched as a window around that date via
// PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
// loaded page even when it would otherwise be hundreds of entries past
// the newest-first cursor. Subsequent pages continue chronologically
// with a `before:<oldest-loaded-TakenAt>` cursor instead of the
// standard offset, so the listing stays in newest-first order without
// jumping around the library. Cleared when the filter changes — a new
// filter is a fresh listing, possibly with its own anchor.
let anchor = $state<PendingFocus | null>(null);
let lastFilterQ: string | null = null;
// Watch every URL change so we catch pending-focus stashes even when the
// filter didn't change (e.g. user clicks the open-folder icon for a photo
// in the folder they're already on — the goto sets the same URL but the
// user still expects to land on THAT photo). Pure filter changes with
// no pending stash clear any stale anchor so a subsequent refetch
// doesn't keep the old window.
$effect(() => {
if (!browser) return;
void page.url.search;
untrack(() => {
const pending = consumePendingFocus();
if (pending) {
anchor = pending;
lastFilterQ = filtersToQ(filters);
return;
}
const q = filtersToQ(filters);
if (q !== lastFilterQ) {
anchor = null;
lastFilterQ = q;
}
});
});
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
queryFn: ({ pageParam }) =>
listPhotos({
q: filtersToQ(filters),
queryKey: [
"photos",
"q",
filtersToQ(filters),
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
],
queryFn: ({ pageParam }) => {
const offset = pageParam as number;
const baseQ = filtersToQ(filters);
// Page 0 + anchor → load a window around the anchor's date.
// Subsequent pages aren't reachable in anchor mode (see
// getNextPageParam).
if (offset === 0 && anchor?.takenAt) {
return listPhotosAround({
q: baseQ,
takenAt: anchor.takenAt,
afterCount: 30,
beforeCount: 90,
merged: true,
});
}
return listPhotos({
q: baseQ,
count: PHOTOS_PAGE_SIZE,
offset: pageParam as number,
offset,
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,
getNextPageParam: (last, pages) => {
if (last.length === 0) return undefined;
// Anchor mode terminates after page 0 — the user sees the 120-
// photo window around the deep-linked photo. PhotoPrism's
// `before:` cursor is day-precision, so paginating further
// chronologically risks dense-day infinite loops (same-day
// photos exceeding the page size keep the cursor at the same
// value). To "see more," the user clears the anchor by
// navigating fresh.
if (anchor?.takenAt) return undefined;
return pages.length * PHOTOS_PAGE_SIZE;
},
enabled: isAuthenticated(),
}));
@@ -176,7 +246,12 @@
const dedupedAll = $derived<PpPhoto[]>(
dedupedPhotos(photosQuery.data?.pages),
);
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
// `removedIds` hides tiles the instant a removing action (archive / delete /
// restore) succeeds, so the grid updates without waiting on the server-
// reconcile refetch (see bulkAction store / BulkActionBar).
const photos = $derived<PpPhoto[]>(
applyFolderScope(dedupedAll, filters).filter((p) => !removedIds.has(p.UID)),
);
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return [];
const seen = new Set<string>();
@@ -237,7 +312,22 @@
// Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0].
if (pages !== 1) return;
// Anchor (from an in-app deep link) takes precedence — its UID is
// guaranteed in `photos` because page 0 was fetched as a window
// around its TakenAt. Plain navigations leave anchor null and we
// snap to photos[0] as before. `scrollToIndex` expands the
// windowed render set + scrolls the tile into view (with sticky-
// header peek) — same helper gridKeyNav uses for arrow nav.
const targetIdx =
anchor?.uid != null
? photos.findIndex((p) => p.UID === anchor!.uid)
: -1;
if (targetIdx >= 0) {
setFocused(photos[targetIdx].UID);
void scrollToIndex(targetIdx);
} else {
setFocused(photos[0].UID);
}
});
});
@@ -630,6 +720,7 @@
return;
}
emptyingArchive = true;
const tid = toast.loading("Emptying archive…");
let total = 0;
try {
while (true) {
@@ -645,9 +736,9 @@
await batchDelete(uids);
total += uids.length;
}
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`);
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Empty archive failed");
toast.error(err instanceof Error ? err.message : "Empty archive failed", { id: tid });
} finally {
emptyingArchive = false;
void qc.invalidateQueries({ queryKey: ["photos"] });

View File

@@ -299,6 +299,37 @@
markersOnScreen.clear();
markers.clear();
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
// (+ optional `zoom`, `focus`) flies the map directly to the photo
// rather than fitting to the full library extent. Strip the params
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
// through to the default fitBounds when the params aren't present.
const sp = new URL(window.location.href).searchParams;
const latParam = Number(sp.get('lat'));
const lngParam = Number(sp.get('lng'));
if (
(data.features?.length ?? 0) > 0 &&
Number.isFinite(latParam) &&
Number.isFinite(lngParam) &&
sp.has('lat') &&
sp.has('lng')
) {
const zoom = Number(sp.get('zoom')) || 17;
map.jumpTo({ center: [lngParam, latParam], zoom });
const stripped = new URL(window.location.href);
stripped.searchParams.delete('lat');
stripped.searchParams.delete('lng');
stripped.searchParams.delete('zoom');
stripped.searchParams.delete('focus');
const qs = stripped.searchParams.toString();
void goto(`/map${qs ? `?${qs}` : ''}`, {
replaceState: true,
keepFocus: true,
noScroll: true
});
return;
}
// Fit to data extent on the first non-empty load — prefer the
// server-provided bbox (PhotoPrism returns one), else compute from
// the features.

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getPhoto,
listPhotosWithNotes,
type PhotoWithNote
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// Photos with a non-empty Details.Notes. The query is shared by the
// LeftSidebar's count badge ($derived off the same key), so visiting
// /notes warms the badge and vice-versa. Keyed under the ['photos', …]
// prefix so the existing mutation invalidations cascade in.
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
queryKey: ['photos', 'with-notes'],
queryFn: listPhotosWithNotes,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const items = $derived<PhotoWithNote[]>(notesQuery.data ?? []);
const count = $derived(items.length);
// Right-sidebar metadata for the focused tile. Same wiring as the tag
// drill-in page so the metadata panel reads consistently.
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
}));
</script>
<Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Notes
</span>
{#if !notesQuery.isPending && !notesQuery.isError}
<span class="text-[11px] text-muted-foreground">
{count} photo{count === 1 ? '' : 's'}
</span>
{/if}
</Toolbar>
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if notesQuery.isPending}
<SkeletonGrid />
{:else if notesQuery.isError}
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load notes" />
{:else if items.length === 0}
<EmptyState
icon={StickyNote}
title="No photos with notes"
description="Add a note to a photo from its metadata sidebar and it will appear here."
/>
{:else}
<NotesPhotoGrid {items} />
{/if}
</main>
<BulkActionBar />
</div>
{#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}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/if}
</div>
<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>

View File

@@ -15,7 +15,6 @@
approve). The previous section is restored on unmount.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import {
@@ -116,7 +115,7 @@
count: g.photos.length as number | undefined
})),
{ id: 'stacks', label: 'Stacks', count: stacksCount },
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
]);
const requestedTab = $derived(page.url.searchParams.get('tab'));
@@ -138,49 +137,18 @@
});
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
function setTab(id: Tab) {
const params = new URLSearchParams();
// First cause tab (if any) is the default — same convention as
// the old /review behaviour, so back-from-cross-folder lands on
// the user's review queue rather than the empty Stacks panel.
const defaultId = tabs[0]?.id;
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
void goto(`/review${params.size ? '?' + params : ''}`, {
keepFocus: true,
noScroll: true
});
}
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review
</span>
{#if tabs.length > 0}
<div class="flex items-center gap-1">
{#each tabs as t (t.id)}
<button
type="button"
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)}
>
<span>{t.label}</span>
{#if t.count !== undefined}
<span
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
t.id
? 'bg-primary/15 text-primary'
: 'bg-secondary text-muted-foreground'}"
>
{t.count}
</span>
{#if activeTabSpec}
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
{#if activeTabSpec.count !== undefined}
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
{#snippet trailing()}
<div
@@ -244,7 +212,7 @@
<p>
The indexer flags photos with a low quality score for human review. New
arrivals with missing EXIF, low resolution, or unknown cameras will land
here. The Stacks and Cross-folder tabs above stay available for
here. The Stacks and Duplicates tabs above stay available for
duplicate cleanup.
</p>
{/snippet}
@@ -267,7 +235,7 @@
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/if}

View File

@@ -6,6 +6,7 @@
getPhoto,
listLabels,
listPhotos,
listPhotosByUids,
listSubjects,
type PhotoMarksMap,
type PpLabel,
@@ -97,10 +98,14 @@
enabled: isAuthenticated() && useLocal,
staleTime: 60_000
}));
// Resolve the pool from the marked UIDs themselves (complete set, any age)
// rather than the newest-N timeline slice, so an old marked photo still
// lands in its color/rating bucket.
const markedUids = $derived(Object.keys(marksQuery.data ?? {}));
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated() && useLocal
queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
queryFn: () => listPhotosByUids(markedUids),
enabled: isAuthenticated() && useLocal && markedUids.length > 0
}));
const ratingGroups = $derived(
@@ -226,6 +231,7 @@
<PhotoGrid photos={drillPhotos} />
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
@@ -270,5 +276,3 @@
{/if}
</div>
{/if}
<BulkActionBar />

10
web/static/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 73 100">
<style>
path { fill: #0f172a; }
@media (prefers-color-scheme: dark) {
path { fill: #f8fafc; }
}
</style>
<path d="m45.268 55.124q-13.062 14-22.732 14-6.206 0-11.546-4.7629v3.8248q0 6.6391 2.0193 13.351 2.3093 7.4328 2.3093 9.8145 0 3.3195-2.0925 5.4846-2.0924 2.165-5.1956 2.165-3.1753 0-5.0515-2.5257-1.8753-2.5257-1.8753-6.0618 0-2.598 1.8753-9.3814 2.1657-7.5053 2.1657-14.577v-65.452h11.907v42.792q0 6.495 1.0817 9.5259 1.1549 3.031 3.9692 4.9795 2.8867 1.8764 6.495 1.8764 6.4225 0 16.67-8.8042v-50.371h11.979v50.153q0 6.3504 1.299 8.8042 1.2989 2.3815 4.4023 2.3815 4.907 0 6.3504-9.5979h2.5979q-1.3721 16.381-13.856 16.381-5.4122 0-9.0207-3.6082-3.536-3.6804-3.7525-10.392z"/>
</svg>

After

Width:  |  Height:  |  Size: 804 B