48 Commits

Author SHA1 Message Date
634abc2a95 feat(settings,index): per-user index sub-path, strip dead PP settings, scope duplicates
Lets a user pick a sub-folder under their library as a working index root,
stored server-side (new sidecar user_prefs table). The Library tree, reindex,
and both duplicate views (stacks + cross-folder scan) now re-root to it via a
single userLibraryBase() helper. Also fixes the cross-folder scan/archive
endpoints, which previously walked/touched the whole originals root instead
of being scoped per-user (archive now rejects out-of-scope paths, 403).

Removes PhotoPrism settings (Search/Maps/Server-UI/Features/Import) that only
steered PhotoPrism's own bundled SPA and were never read by mulimage's UI.

Also fixes the Library tree occasionally getting stuck on "Loading folders…"
by dropping gcTime:0 and gating the spinner on isLoading instead of isPending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-06-30 22:41:33 +02:00
ba5684d120 feat(countries): replace Map view with browse-by-country (like Tags)
Removes the maplibre-gl Map view and adds "Countries" as a sixth
TagCategory, reusing the existing /tags/[category]/[[value]] browse
machinery instead of a bespoke map UI. Backed by a new self-contained
sidecar endpoint that aggregates photos.photo_country with BasePath
scoping, mirroring handleLabels/handleScopedCounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-06-30 21:25:24 +02:00
74bae78270 fix(duplicates): use server-side path filter instead of client-side filtering
Move basePath filtering from client-side startsWith check to server-side
query filter (path:basePath*) for consistency with map implementation
and improved efficiency. Reduces amount of data fetched when user has
a basePath configured.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-30 21:08:33 +02:00
52ab3b6840 fix(map): remove coordinate swap, properly format basePath filter with quoting
Revert coordinate transformation (PhotoPrism already returns correct [lng,lat] format).
Fix basePath filter query string to properly quote paths with special characters
and add wildcard suffix using same quoteIfNeeded logic as filters store.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-30 21:08:04 +02:00
400b215036 fix(sidebar,map,duplicates): flatten sidebar hierarchy, filter by user basePath, fix map coordinates
- Flatten sidebar: remove collapsible Tags and Review sections, place all items at level-0
  Notes, tag categories (Labels/Keywords/People/Colors/Ratings) now appear directly in Views
  Review tabs (Causes/Stacks/Duplicates) and Hidden appear directly in Manage
- Filter duplicates by user base path to ensure multi-tenant isolation
  listDuplicateGroups now accepts optional basePath parameter
  update review page and sidebar to pass userBasePath() for proper per-user caching
- Filter map geo data by user base path using path: query filter
  map page now only shows geotagged photos from current user's library
- Fix map coordinate positioning: PhotoPrism /geo endpoint returns [lat,lng]
  but GeoJSON and MapLibre expect [lng,lat]. Transform coordinates and bbox
  on data receive to fix photo placement and zoom behavior

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-30 21:04:56 +02:00
e1e508671e fix(move): resolve full per-photo file list so videos actually move
The move resolved photos via the /photos search, whose merged Files array is
trimmed (often omitting a photo's video file) and which applies PhotoPrism's
quality/review/archive filters — so a video's .mov was never listed to move
and nothing happened. Resolve each UID via GET /photos/:uid instead (full file
list, no filters), shared by photos-move and heap-convert via resolvePhotosFull.
Unresolved UIDs are reported as skipped rather than aborting the batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:52:56 +02:00
a52f171946 fix(folders): translate BasePath for create/rename/delete to fix "invalid path"
The sidebar shows user-relative paths (BasePath stripped) but the sidecar
operates on originals-relative paths. Folder create/rename/delete passed the
stripped path straight through, so a BasePath user's ops resolved to the wrong
directory and the sidecar returned "invalid path". Wrap outgoing paths with
toOriginalsPath and map returned paths back with toUserPath, matching the move
flow. Identity for admin accounts (empty BasePath).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
e124809ad5 fix(move): move every originals file of a photo, not just the primary
Videos, Live Photos, and RAW+JPG pairs keep several files under Root "/". The
old movePhotoFiles moved only the primary (often the poster JPG), orphaning
the .mov: PhotoPrism then saw the photo as moved (dropped from the grid) while
the video stayed behind and broke. Move the whole originals group under one
shared stem (new uniqueStem helper) so siblings re-stack after reindex; fail
the photo and report it if any sibling can't move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
ad6e733622 fix(move): close the dialog when a move starts so header progress shows
The move dialog held its full-screen overlay open for the whole operation,
hiding exactly the header reindex/status pill the user waits on. Snapshot the
draft state, closeMove() up front, and run the move in the background with a
toast.loading→success/error — mirrors the archive flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
6d9b236ef6 feat(library): reindex button, live grid, archive-reappear fix, bigger carets
- Add one-click "reindex new files" button to the Library sidebar header
  (RefreshCw, calls startIndex rescan:false), spins + disables while active.
- Refresh the photos grid from the indexer WS stream (throttled during the
  scan + once on completion) so newly indexed files appear live.
- Fix archived photos flashing back into the grid when archiving others:
  drop the per-action settle-driven clearRemoved and reconcile removedIds
  against the actual cache instead (clears an id only once it's gone from
  the deduped pages). Covers archive, delete, and bulk-bar removals.
- Replace the tiny Unicode caret triangles with a 16px Lucide ChevronRight
  that rotates 90deg on expand, across folder tree rows, root, Tags, Review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:48:18 +02:00
669e5fde33 Merge pull request 'feat(move): "move to folder" for grid selections, folders, and m shortcut' (#3) from claude/infallible-sammet-3471fa into main
Reviewed-on: #3
2026-06-18 00:40:25 +02:00
b2b6060872 feat(move): "move to folder" for grid selections, folders, and m shortcut
Extends the heap-only "move to folder" action to grid single/bulk
selections, sidebar folders, and an `m` keyboard shortcut — all through
one shared dialog driven by a moveDialog store.

Backend (sidecar):
- Extract the heap move/copy + reindex loop into a reusable movePhotoFiles
  helper plus resolveMoveTarget
- POST /photos/move: move/copy an arbitrary UID list into a folder
- POST /folders/:rel/move: reparent a folder dir (whole subtree) under a
  new parent, guarding against moving into itself/a descendant

Frontend:
- moveDialog store + generalized MoveToFolderDialog (heap | photos | folder
  subjects); mounted once in +layout.svelte. Replaces HeapConvertDialog
- movePhotosToFolder / moveFolder service fns
- Entry points: BulkActionBar button, gridKeyNav `m`, FolderTree kebab,
  heap kebab — all call openMove()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:10:19 +02:00
277fdc5a53 Merge pull request 'feat(archive): red-cross flash + instant tile removal on archive/delete' (#2) from claude/infallible-sammet-3471fa into main
Reviewed-on: #2
2026-06-17 23:58:21 +02:00
5be6fd9047 feat(archive): red-cross flash + instant tile removal on archive/delete
Archive/delete now flash a red cross then drop tiles from the grid
immediately, instead of a green check that lingered until the slow
server-reconcile refetch landed. Keyboard `x` archive previously never
called markRemoved, so tiles only vanished on refetch — that lag is gone.

- Add 'removed' bulk state + removedBulk() helper (red cross overlay)
- gridKeyNav archive/delete: removedBulk -> 500ms flash -> markRemoved,
  clearRemoved once refetch settles; restore stays green check
- BulkActionBar: BulkConfig.removing routes archive/delete through the
  red flash; approve/restore/label/note unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:45:38 +02:00
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
70 changed files with 5123 additions and 2900 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"`
@@ -26,6 +26,21 @@ type Mark struct {
// nothing surprising lands in the schema.
func (Mark) TableName() string { return "marks" }
// UserPref holds the per-user, server-side preferences PhotoPrism's account
// model has no slot for. Today that's just `IndexPath` — the originals-
// relative sub-folder (under the user's BasePath) the web client re-roots the
// Library tree to and scopes the reindex to. Empty string = "whole folder".
// Keyed by username so each user has independent prefs, matching `Mark`.
type UserPref struct {
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
IndexPath string `gorm:"size:1024;column:index_path" json:"indexPath"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
}
// TableName pins the table name (GORM would pluralise to `user_prefs` anyway,
// but pin it explicitly to stay consistent with Mark).
func (UserPref) TableName() string { return "user_prefs" }
// asJSON returns the wire shape clients expect — same flat object the
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
// as `{}` which the client treats as "no mark on this photo".
@@ -56,7 +71,7 @@ func openDB(dsn string) (*gorm.DB, error) {
if err != nil {
return nil, err
}
if err := db.AutoMigrate(&Mark{}); err != nil {
if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil {
return nil, err
}
return db, nil

View File

@@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
return "", "", false
}
// uniqueStem finds a base name (extension stripped) that is free for *every*
// extension in `exts` under destDir, appending `-1`, `-2`, … on collision —
// the multi-file analogue of uniqueName. Moving a photo's originals siblings
// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps
// PhotoPrism stacking them as one photo after reindex; picking the stem once
// for the whole group is what stops the video from being orphaned under a
// differently-suffixed name than its poster. Caps at 1000 attempts to match
// uniqueName. The passed extensions keep their on-disk case (we compare
// case-sensitively via os.Stat, which is correct on the case-sensitive
// volumes PhotoPrism targets).
func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) {
base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase))
for i := 0; i < 1000; i++ {
candidate := base
if i > 0 {
candidate = base + "-" + itoa(i)
}
free := true
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) {
free = false
break
}
}
if free {
return candidate, true
}
}
return "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
if n == 0 {

View File

@@ -0,0 +1,70 @@
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PpCountry is the country aggregation row returned to the client: a
// 2-letter ISO 3166-1 code, the user-scoped photo count, and a representative
// thumb hash for the sidebar row.
type PpCountry struct {
Code string `json:"Code"`
PhotoCount int `json:"PhotoCount"`
Thumb string `json:"Thumb"`
}
// handleCountries aggregates photos.photo_country directly against
// PhotoPrism's DB (no upstream proxy needed — this is a simple GROUP BY)
// and scopes the result to the caller's BasePath, mirroring handleLabels.
//
// Route: GET /api/sidecar/countries (behind requireSession, ppDb != nil)
func handleCountries(ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
basePath := ctxBasePath(c)
type countryStat struct {
Code string `gorm:"column:code"`
Cnt int64 `gorm:"column:cnt"`
ThumbHash string `gorm:"column:thumb_hash"`
}
var stats []countryStat
query := ppDb.Table("photos p").
Select(`p.photo_country AS code,
COUNT(DISTINCT p.id) AS cnt,
COALESCE(MIN(f.file_hash), '') AS thumb_hash`).
Joins(`LEFT JOIN files f ON f.photo_uid = p.photo_uid
AND f.file_primary = 1
AND f.file_missing = 0`).
Where("p.deleted_at IS NULL").
Where("p.photo_country != '' AND p.photo_country != 'zz'")
if basePath != "" {
prefix := basePath + "/%"
query = query.Where("(p.photo_path = ? OR p.photo_path LIKE ?)", basePath, prefix)
}
if err := query.
Group("p.photo_country").
Having("cnt > 0").
Order("cnt DESC").
Scan(&stats).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "country stats query failed"})
return
}
out := make([]PpCountry, 0, len(stats))
for _, s := range stats {
out = append(out, PpCountry{
Code: s.Code,
PhotoCount: int(s.Cnt),
Thumb: s.ThumbHash,
})
}
c.JSON(http.StatusOK, out)
}
}

View File

@@ -9,10 +9,12 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const quarantineDir = ".duplicates"
@@ -36,17 +38,41 @@ type dupListPhoto struct {
Files []ppFile `json:"Files"`
}
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
start := time.Now()
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
all, err := walkFiles(cfg.OriginalsRoot)
// Scope the walk to the user's effective library root (BasePath +
// chosen index sub-path), same as the folders/timeline/reindex scope —
// otherwise a narrowed root would still surface every other user's
// files in the cross-folder duplicate scan. "" means whole library
// (today's admin-without-BasePath default).
root := effectiveLibraryRoot(c, db)
scanRoot := cfg.OriginalsRoot
if root != "" {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"})
return
}
scanRoot = abs
}
slog.Info("dup.scan starting", "root", scanRoot)
all, err := walkFiles(scanRoot)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// walkFiles computes RelPath relative to scanRoot; re-prefix with the
// scoped sub-path so RelPath stays originals-root-relative, matching
// what handleDupArchive (and the rest of the API) expects.
if root != "" {
for i := range all {
all[i].RelPath = root + "/" + all[i].RelPath
}
}
// Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip
@@ -149,7 +175,7 @@ type dupArchiveErr struct {
Error string `json:"error"`
}
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupArchiveBody
@@ -158,6 +184,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return
}
// Authz: every path must live under the caller's effective library
// root. The scan above already only ever returns paths from there,
// but this endpoint takes paths straight from the request body, so a
// scoped (non-admin, or admin-with-sub-path) user could otherwise
// pass an arbitrary originals-relative path and archive (move) files
// outside their own folder.
root := effectiveLibraryRoot(c, db)
if root != "" {
for _, p := range body.Paths {
clean := strings.Trim(p, "/")
if clean != root && !strings.HasPrefix(clean, root+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
return
}
}
}
// Each archive batch lands in its own timestamped subdir so the
// user can browse what was quarantined when (and recover by hand
// if they change their mind).

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

View File

@@ -83,35 +83,17 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
subfolder = s
}
// Resolve destination. resolveUnderRoot ensures the target lives
// inside ORIGINALS_ROOT and that its parent is a real directory.
// Empty / "/" / "." are valid here — they mean "drop these into
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
// rejects those for safety, so handle the root case explicitly.
var targetAbs string
trimmed := strings.Trim(body.TargetFolder, "/")
if trimmed == "" || trimmed == "." {
targetAbs = cfg.OriginalsRoot
} else {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
targetAbs = abs
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
// "drop these into originals/ itself" (the modal's "Root" option).
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if err := os.MkdirAll(destAbs, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
// Pull the heap's photos via the q=album:UID query. count=1000 covers
// every realistic heap; merged=true expands stacked variants so we
// move the JPG/HEIC sibling alongside the primary.
// Pull the heap's membership via the q=album:UID query (count=1000
// covers every realistic heap). We only need the UID list here — the
// search's Files array is trimmed and drops videos, so we re-resolve
// each photo's full file set below via resolvePhotosFull.
q := url.QueryEscape("album:" + albumUID)
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
@@ -123,114 +105,31 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
var listed []heapPhoto
if err := json.Unmarshal(resp.Body, &listed); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
sourceParents := map[string]struct{}{}
errs := []heapErr{}
moved, copied := 0, 0
for _, photo := range photos {
// Pick the file to physically move. PhotoPrism's "primary" file
// for a HEIC photo is the generated `.HEIC.jpg` preview that
// lives in storage/sidecar (Root=="sidecar"), not in originals
// — moving that path would fail "file missing on disk" every
// time. Prefer the primary that lives in originals (Root=="/")
// and fall back to the first originals-rooted file. PhotoPrism
// regenerates sidecars on reindex, so they don't need to follow.
var file ppFile
found := false
for _, f := range photo.Files {
if f.Root == "/" && f.Primary {
file, found = f, true
break
}
}
if !found {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue
}
srcRel := file.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
continue
}
st, err := os.Stat(srcAbs)
if err != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if err := os.Rename(srcAbs, dstAbs); err != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
if err2 := os.Remove(srcAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
continue
}
}
moved++
} else {
if err := copyFile(srcAbs, dstAbs); err != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
copied++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
uids := make([]string, 0, len(listed))
for _, p := range listed {
uids = append(uids, p.UID)
}
// Reindex the destination + every source parent so PhotoPrism's
// DB catches up. We block on these so the response only goes out
// after the index reflects the move — callers (the frontend's
// invalidateQueries refetch in particular) need the next /photos
// fetch to return the moved files, otherwise the folder view
// looks unchanged. PhotoPrism's index endpoint serialises calls
// internally; running them sequentially matches that contract
// without surprising the server.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
// Re-fetch each photo's complete file list so videos (and other multi-
// file photos) move whole — the album search alone would orphan the
// .mov. See resolvePhotosFull.
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, uids)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
paths[parent] = struct{}{}
}
for p := range paths {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
errs = append(resolveErrs, errs...)
heapDeleted := false
if deleteHeap {
@@ -260,3 +159,180 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
})
}
}
// movePhotoFiles moves (or copies) each photo's originals-rooted primary file
// into targetAbs — optionally into `subfolder` under it — then blocks on a
// PhotoPrism reindex of the destination plus every source parent so the next
// /photos fetch reflects the move. Shared by handleHeapConvert (album-scoped)
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
// but move them identically. Returns per-photo errors in `errs`; the returned
// top-level error is only for a fatal precondition (subfolder mkdir failed).
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if e := os.MkdirAll(destAbs, 0o755); e != nil {
return 0, 0, nil, e
}
}
sourceParents := map[string]struct{}{}
errs = []heapErr{}
for _, photo := range photos {
// Gather *every* originals-rooted file of the photo, not just the
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
// must travel together — moving only the primary orphans the rest, so
// the photo looks "moved" in PhotoPrism (the poster defines its path)
// while the actual video is left behind and silently breaks. Sidecar-
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
// on reindex and intentionally skipped. Pick the stem from the primary
// (or the first originals file) so the siblings re-stack under one name.
var group []ppFile
var primary ppFile
havePrimary := false
for _, f := range photo.Files {
if f.Root != "/" {
continue
}
group = append(group, f)
if f.Primary && !havePrimary {
primary, havePrimary = f, true
}
}
if len(group) == 0 {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue
}
if !havePrimary {
primary = group[0]
}
// Choose one collision-free stem for the whole group up front, so the
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
exts := make([]string, 0, len(group))
extSeen := map[string]struct{}{}
for _, f := range group {
ext := filepath.Ext(f.Name)
if _, dup := extSeen[ext]; !dup {
extSeen[ext] = struct{}{}
exts = append(exts, ext)
}
}
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
// Move/copy each sibling. A failure on any one fails the whole photo
// (surfaced in errs) rather than leaving a half-moved stack unreported.
var failure string
movedAny := false
usedNames := map[string]struct{}{}
for _, f := range group {
srcRel := f.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
failure = "path escapes originals"
break
}
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() {
failure = "file missing on disk"
break
}
if filepath.Dir(srcAbs) == destAbs {
// Already in the target folder — nothing to do for this sibling,
// but the photo isn't an error just because one file is in place.
continue
}
name := stem + filepath.Ext(srcAbs)
// Two originals files sharing an extension (rare) would collide on
// the shared stem; keep the extra one's own unique name so neither
// overwrites the other.
if _, clash := usedNames[name]; clash {
_, n, uok := uniqueName(destAbs, filepath.Base(srcAbs))
if !uok {
failure = "too many collisions"
break
}
name = n
}
usedNames[name] = struct{}{}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
failure = mvErr.Error()
break
}
if err2 := os.Remove(srcAbs); err2 != nil {
failure = "rename ok, source remove failed: " + err2.Error()
break
}
}
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
failure = cpErr.Error()
break
}
}
movedAny = true
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
if failure != "" {
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
continue
}
if !movedAny {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
if mode == "move" {
moved++
} else {
copied++
}
}
// Reindex the destination + every source parent so PhotoPrism's DB
// catches up. We block on these so the response only goes out after the
// index reflects the move — the frontend's invalidateQueries refetch
// needs the next /photos fetch to return the moved files, otherwise the
// folder view looks unchanged. PhotoPrism's index endpoint serialises
// calls internally; running them sequentially matches that contract.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
}
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
paths[parent] = struct{}{}
}
for p := range paths {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
}
return moved, copied, errs, nil
}
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
// mean the Originals root itself) into a validated absolute path under the
// root. Shared by the heap-convert and photos-move destination handling.
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
trimmed := strings.Trim(targetFolder, "/")
if trimmed == "" || trimmed == "." {
return cfg.OriginalsRoot, nil
}
return resolveUnderRoot(cfg.OriginalsRoot, targetFolder, true)
}

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
}

196
sidecar/handlers_move.go Normal file
View File

@@ -0,0 +1,196 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
type photosMoveBody struct {
UIDs []string `json:"uids"`
TargetFolder string `json:"targetFolder"`
Mode string `json:"mode"` // "move" or "copy"
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
}
// handlePhotosMove moves/copies an arbitrary list of photos (by UID) into a
// folder under originals/. Mirrors handleHeapConvert but resolves the photos
// from a UID list instead of an album query, then shares movePhotoFiles for
// the on-disk work + reindex. Backs the grid's "Move to folder" action.
func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body photosMoveBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
if len(body.UIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no uids"})
return
}
mode := body.Mode
if mode != "copy" {
mode = "move"
}
var subfolder string
if body.Subfolder != "" {
s, ok := sanitizeFilename(body.Subfolder)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
return
}
subfolder = s
}
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
// Resolve each photo's FULL file list via the single-photo endpoint
// rather than the /photos search (see resolvePhotosFull) — the search
// drops a photo's video file from its trimmed Files array and filters
// videos out by quality/review, so the .mov never gets listed to move.
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, body.UIDs)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Surface UIDs PhotoPrism couldn't resolve alongside any per-file
// errors so the client's "N skipped" summary stays accurate.
errs = append(resolveErrs, errs...)
slog.Info("photos.move",
"requested", len(body.UIDs),
"mode", mode,
"moved", moved,
"copied", copied,
"errors", len(errs),
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
})
}
}
// resolvePhotosFull fetches each photo's complete file list via the
// single-photo endpoint (GET /photos/:uid). Use this instead of the /photos
// search whenever you need every file of a photo: the search — even with
// merged=true — can return a trimmed Files array that omits the photo's video
// file, and it applies PhotoPrism's default quality/review/archive filters.
// Both silently drop videos (which PhotoPrism routinely files under review)
// from a move. The per-UID lookup returns every file and ignores those
// filters. UIDs PhotoPrism can't resolve are returned in `errs` so the batch
// continues; a transport-level failure aborts with a fatal error. Mirrors
// handleRename's single-photo resolution.
func resolvePhotosFull(ctx context.Context, pp *ppClient, token string, uids []string) (photos []heapPhoto, errs []heapErr, err error) {
photos = make([]heapPhoto, 0, len(uids))
for _, uid := range uids {
resp, e := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil)
if e != nil {
return nil, nil, e
}
if !resp.OK {
errs = append(errs, heapErr{UID: uid, Reason: "photo not found"})
continue
}
var p heapPhoto
if e := json.Unmarshal(resp.Body, &p); e != nil {
return nil, nil, e
}
photos = append(photos, p)
}
return photos, errs, nil
}
type folderMoveBody struct {
// Originals-relative destination parent. ""/"/"/"." mean the root.
TargetParent string `json:"targetParent"`
}
// handleFolderMove reparents a folder: moves the directory (and everything in
// it) under a different parent, keeping its own name. Mirrors
// handleFolderRename but the destination is a parent folder rather than a new
// name. A whole-tree os.Rename preserves subfolder structure.
func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body folderMoveBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
targetParentAbs, err := resolveMoveTarget(cfg, body.TargetParent)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
return
}
// Can't move a folder into itself or one of its own descendants.
if sameOrUnder(targetParentAbs, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
return
}
newAbs := filepath.Join(targetParentAbs, filepath.Base(oldAbs))
if newAbs == oldAbs {
c.JSON(http.StatusBadRequest, gin.H{"error": "already in that folder"})
return
}
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
return
}
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
return
}
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
slog.Info("folder.move", "from", oldRel, "to", newRel)
// Reindex both the old and new parents so PhotoPrism drops the moved
// rows from the source view and picks them up under the destination.
fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
fireReindex(cfg, pp, token, "/"+filepath.Dir(newRel))
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldPath": oldRel,
"newPath": newRel,
})
}
}

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

117
sidecar/handlers_prefs.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Per-user preferences the PhotoPrism account model can't hold. Currently a
// single field — the index sub-path the web client re-roots the Library tree
// to and scopes the reindex to. Stored in the sidecar's own DB keyed by
// username (see UserPref in db.go); never touches PhotoPrism's auth_users.
// prefsBody is the wire shape for GET responses and PUT requests alike.
type prefsBody struct {
IndexPath string `json:"indexPath"`
}
// loadUserPref reads the row for a user, returning a zero-value pref (empty
// IndexPath) when none exists yet — the "whole folder" default.
func loadUserPref(db *gorm.DB, userName string) (UserPref, error) {
var p UserPref
err := db.Where("user_name = ?", userName).First(&p).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return UserPref{UserName: userName}, nil
}
return p, err
}
func handlePrefsGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
p, err := loadUserPref(db, ctxUserName(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, prefsBody{IndexPath: p.IndexPath})
}
}
// handlePrefsPut validates the requested index sub-path lives under the user's
// BasePath (an existing directory, no traversal) and upserts it. An empty
// string clears the sub-path back to "whole folder".
func handlePrefsPut(cfg *Config, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body prefsBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
// Normalise to originals-relative, no leading/trailing slashes —
// the same shape the web client and auth_users.base_path use.
sub := strings.Trim(strings.TrimSpace(body.IndexPath), "/")
if sub != "" {
// The sub-path is relative to the user's BasePath; resolve the
// combined originals-relative path and require it to be an
// existing directory inside the originals root. resolveUnderRoot
// already rejects traversal and symlink escapes.
base := strings.Trim(ctxBasePath(c), "/")
combined := sub
if base != "" {
combined = base + "/" + sub
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, combined, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index path: " + err.Error()})
return
}
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "index path is not a folder"})
return
}
}
userName := ctxUserName(c)
p := UserPref{UserName: userName, IndexPath: sub, UpdatedAt: time.Now().UTC()}
// Upsert: a clear (sub == "") persists an empty string rather than
// deleting the row, so the GET path stays a single code branch.
if err := db.Save(&p).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, prefsBody{IndexPath: sub})
}
}
// effectiveLibraryRoot returns the requesting user's working library root,
// originals-relative with no leading/trailing slash: their BasePath narrowed
// by their chosen index sub-path (if any). Mirrors the web client's
// `userLibraryBase()` — handlers that walk the filesystem on a user's behalf
// (duplicate scan/archive) should scope to this instead of cfg.OriginalsRoot
// so a narrowed root also narrows what those handlers can see or touch.
// Returns "" for "whole library" (no BasePath and no sub-path set — today's
// admin default).
func effectiveLibraryRoot(c *gin.Context, db *gorm.DB) string {
base := strings.Trim(ctxBasePath(c), "/")
pref, err := loadUserPref(db, ctxUserName(c))
sub := ""
if err == nil {
sub = strings.Trim(pref.IndexPath, "/")
}
if sub == "" {
return base
}
if base == "" {
return sub
}
return base + "/" + sub
}

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
@@ -66,25 +79,49 @@ func main() {
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
}
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
auth.GET("/countries", handleCountries(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)
srv := &http.Server{

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 {

247
web/package-lock.json generated
View File

@@ -14,7 +14,6 @@
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"lucide-svelte": "^1.0.1",
"maplibre-gl": "^5.24.0",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
@@ -148,110 +147,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.1"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.8.5",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
"integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz",
"integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.4",
"@maplibre/geojson-vt": "^5.0.4",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"pbf": "^4.0.1",
"supercluster": "^8.0.1"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
"license": "ISC"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -998,12 +893,6 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
@@ -1014,15 +903,6 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1248,12 +1128,6 @@
"node": ">= 0.4"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.21.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
@@ -1451,12 +1325,6 @@
"node": ">= 0.4"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1553,18 +1421,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1879,40 +1735,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1952,15 +1774,6 @@
"node": ">= 0.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mode-watcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
@@ -2050,12 +1863,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -2086,18 +1893,6 @@
],
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2147,18 +1942,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -2168,12 +1951,6 @@
"node": ">=10"
}
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2188,15 +1965,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2309,15 +2077,6 @@
"inline-style-parser": "0.2.7"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/svelte": {
"version": "5.55.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
@@ -2489,12 +2248,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",

View File

@@ -30,7 +30,6 @@
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"lucide-svelte": "^1.0.1",
"maplibre-gl": "^5.24.0",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",

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,8 +10,12 @@ 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 { openMove } from '$lib/stores/moveDialog.svelte';
import {
clearBulkToFirst,
clearSelection,
@@ -24,8 +28,15 @@ import {
toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import {
startBulk,
doneBulk,
removedBulk,
failBulk,
setDetail,
markRemoved
} 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,34 +171,7 @@ 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]);
}
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
const ids = cullTargets();
@@ -207,34 +191,43 @@ 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.
focusAfter(ids);
// Drop the now-stale selection set. The archived UIDs are about
// to leave the timeline on refetch, but the SvelteSet membership
// keeps the selection ring on them until then — confusing for
// the user and a footgun if they Ctrl-click to add more and end
// up re-archiving the same photos. The BulkActionBar button path
// clears for the same reason; mirror it here.
clearSelection();
invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
toast.success(label);
pushUndo(label, async () => {
if (target) {
// Destructive removal: flash a red cross, then pull the tiles out of
// the grid immediately (markRemoved) rather than waiting on the slow
// server-reconcile refetch. The grid reconciles `removedIds` against
// the cache and drops each id once the archived-filtered page has
// actually replaced it (see +page.svelte), so we don't clear here —
// clearing on this action's own settle raced other in-flight archives
// and flashed photos back in.
removedBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] });
} else {
doneBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
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 +252,27 @@ 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;
}
// Destructive removal — same red-cross flash then immediate hide as archive.
removedBulk(`Deleted ${ids.length}`, ids);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
toast.success(`Deleted ${ids.length}`);
// removedIds is reconciled against the cache in +page.svelte; no
// settle-driven clear here (see toggleArchive note above).
void queryClient.invalidateQueries({ queryKey: ['photos'] });
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 +288,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 +334,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 +363,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 +512,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':
@@ -519,6 +556,23 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault();
void toggleArchive('restore');
return;
case 'm':
case 'M': {
if (meta || shift) return;
e.preventDefault();
// Move the cull targets to a folder — opens the shared
// move-to-folder dialog (same one the bar button and the
// heap/folder kebabs use).
const moveIds = cullTargets();
if (moveIds.length === 0) {
toast.message('Nothing to move', {
description: 'Click a photo or select some first'
});
return;
}
openMove({ kind: 'photos', uids: moveIds });
return;
}
case 's':
case 'S':
if (meta || shift) return;

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

@@ -28,6 +28,7 @@
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { userLibraryBase } from '$lib/stores/session.svelte';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
@@ -50,14 +51,14 @@
// "Rescan filesystem" button invalidates to force a re-scan after
// the user has moved files around.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates,
enabled: activeTab === 'cross-folder',
staleTime: 5 * 60_000
}));
function rescan() {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] });
}
$effect(() => {
@@ -65,7 +66,7 @@
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
: 'Duplicates scan failed'
);
}
});
@@ -91,7 +92,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 +106,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 +140,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,7 +40,8 @@
<script lang="ts">
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import { untrack } from 'svelte';
import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -49,10 +50,13 @@
depth?: number;
onPick: (path: string) => void;
/** Mutating callbacks are only required when readonly !== true. The
* picker (HeapConvertDialog) reuses the tree just for `onPick`. */
* picker (MoveToFolderDialog) reuses the tree just for `onPick`. */
onRename?: (path: string) => void;
onDelete?: (path: string) => void;
onCreateChild?: (parent: string) => void;
/** Reparent this folder under a chosen destination (opens the shared
* move-to-folder dialog). Sidebar only; the readonly picker omits it. */
onMove?: (path: string) => void;
/** Read-only mode: hides the kebab menu and disables double-click
* rename, so the tree can be reused as a folder picker. */
readonly?: boolean;
@@ -73,6 +77,7 @@
onRename,
onDelete,
onCreateChild,
onMove,
readonly = false,
selectedPath,
counts
@@ -105,6 +110,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>
@@ -128,19 +164,21 @@
>
{#if hasChildren}
<button
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
class:text-muted-foreground={!active}
onclick={() => toggle(node.path)}
title={open ? 'Collapse' : 'Expand'}
aria-label={open ? 'Collapse' : 'Expand'}
>
{open ? '▾' : '▸'}
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
/>
</button>
{:else}
<!-- Spacer keeps childless siblings aligned with their chevroned
peers at every depth, so labels share a common left edge
across the sidebar (folders, heaps, views, manage). -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
{/if}
<!--
Count badge lives INSIDE the button so the entire row (label
@@ -187,6 +225,13 @@
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
Rename
</Item>
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => onMove?.(node.path)}
>
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder…
</Item>
<Separator class="my-1 h-px bg-border" />
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] text-destructive outline-none hover:bg-destructive/10 focus:bg-destructive/10"
@@ -207,6 +252,7 @@
{onRename}
{onDelete}
{onCreateChild}
{onMove}
{readonly}
{selectedPath}
{counts}

View File

@@ -1,24 +1,20 @@
<!--
General app preferences. The UI tab owns the SvelteKit shell's
light/dark/system theme (mode-watcher) plus the per-user UI knobs
PhotoPrism's /settings exposes. Search and Maps follow the same
pattern — server prefs round-trip via /api/v1/settings.
General app preferences. Two tabs: the SvelteKit shell's
light/dark/system theme (mode-watcher) and the signed-in user's account
(identity + password change).
The Library admin dialog and this one share the ['settings'] cache,
so saves from either invalidate the other.
PhotoPrism's own per-user UI/search/maps knobs used to live here too, but
they only steer PhotoPrism's bundled SPA — which mulimage's users never
see — so they were removed. mulimage's own view prefs live in the view
store; the library admin knobs live under Folders → ⚙ (SettingsDialog).
-->
<script lang="ts">
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { createMutation } from '@tanstack/svelte-query';
import { mode, setMode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import {
getSettings,
saveSettings,
setUserPassword,
type PpSettings
} from '$lib/services/photoprism';
import { setUserPassword } from '$lib/services/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props {
@@ -27,9 +23,7 @@
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
let activeTab = $state<'ui' | 'account'>('ui');
// ── Account tab — password change ─────────────────────────────────────
let pwOld = $state('');
@@ -59,107 +53,6 @@
{ value: 'system', label: 'System', Icon: Monitor }
] as const;
// PhotoPrism palette names from its built-in themes. Any value
// outside this list is preserved verbatim (see `withCurrent`).
const ppThemes = [
'default',
'abyss',
'gemstone',
'grayscale',
'lavender',
'legacy',
'neon',
'onyx',
'raspberry',
'shadow',
'yellowstone'
];
// IETF subtags PhotoPrism ships translations for. Extend without
// fear — `withCurrent` keeps unknown values visible.
const ppLanguages = [
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
];
const ppStartPages = [
'default',
'browse',
'albums',
'calendar',
'moments',
'people',
'places',
'labels',
'states',
'library'
];
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
// Returns `opts` with `current` prepended if it's set and not
// already in the list — so e.g. an experimental theme name in the
// server response shows up selected and editable instead of
// silently being overwritten by the dropdown's default.
function withCurrent(opts: string[], current?: string): string[] {
if (!current) return opts;
return opts.includes(current) ? opts : [current, ...opts];
}
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
/**
* Some PhotoPrism deployments return `/settings` without the
* `ui` / `search` / `maps` keys (older versions, custom edits to
* settings.yml). The form's `bind:value={draft.ui!.theme}` etc.
* non-null-asserts those sub-objects — when they're missing the
* assertion lies and the bind getter throws on the next tick. Force
* the shape on every clone so every binding has a real object to
* write into, and so `draft.ui` is never null while `draft` is non-
* null (template gates only check `draft`).
*/
function normalize(s: PpSettings): PpSettings {
return {
...s,
ui: s.ui ?? {},
search: s.search ?? {},
maps: s.maps ?? {}
};
}
let draft = $state<PpSettings | null>(null);
// Re-clone on each open so reopening the dialog shows the freshest
// server state. Eagerly nulling on close used to introduce a window
// where Dialog's exit animation kept the form mounted while draft
// was already null — and bind:value getters read null, triggering
// "$.get(...) is null" / can't access .ui at runtime. Resetting on
// open instead avoids that race entirely.
$effect(() => {
if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = normalize(structuredClone(next));
toast.success('Settings saved');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save settings')
}));
function resetDraft() {
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
}
const selectClass =
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
</script>
@@ -198,7 +91,7 @@
<Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps', 'account'] as const as t (t)}
{#each ['ui', 'account'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
@@ -208,8 +101,7 @@
{/each}
</Tabs.List>
<!-- UI — local app theme (mode-watcher) on top, then the
PhotoPrism per-user UI knobs that go to /settings. -->
<!-- UI — local app theme (mode-watcher). Persists itself; no Save. -->
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -239,126 +131,10 @@
Light/dark for this app. Persists locally; no Save needed.
</p>
</section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading server settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load server settings.</p>
{:else if draft}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
<select bind:value={draft.ui!.theme} class={selectClass}>
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Language</span>
<select bind:value={draft.ui!.language} class={selectClass}>
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Time zone</span>
<!-- IANA tz list is ~400 entries, browser support varies; use
a datalist so we get autocomplete without spamming a
gigantic <select>. "Local" is PhotoPrism's special
"follow system" sentinel. -->
<input
type="text"
list="general-tz-list"
placeholder="Local"
bind:value={draft.ui!.timeZone}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Start page</span>
<select bind:value={draft.ui!.startPage} class={selectClass}>
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
Always show scrollbars
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.zoom} />
Allow image zoom
</label>
</section>
{/if}
</Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
</Tabs.Content>
{:else if draft}
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.listView} />
Default to list view
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showTitles} />
Show titles
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
Show captions
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Batch size (-1 = server default)
</span>
<input
type="number"
bind:value={draft.search!.batchSize}
class={selectClass}
/>
</label>
</Tabs.Content>
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Style</span>
<select bind:value={draft.maps!.style} class={selectClass}>
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Animation duration (ms, 0 = off)
</span>
<input
type="number"
bind:value={draft.maps!.animate}
class={selectClass}
/>
</label>
</Tabs.Content>
{/if}
<!-- Account — independent of /settings; reads from the session
store and round-trips its own mutation. -->
<!-- Account — reads from the session store and round-trips its own
password mutation. -->
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -448,59 +224,6 @@
</form>
</Tabs.Content>
</Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the
"Local" sentinel when the browser can't enumerate the
IANA list (older Safari, etc.). -->
<datalist id="general-tz-list">
<option value="Local"></option>
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
</datalist>
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we
only show the action row when there's something to save.
Account tab has its own Update-password button, so skip. -->
{#if draft && activeTab !== 'account'}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={resetDraft}
disabled={saveMut.isPending}
>
Revert
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => draft && saveMut.mutate(draft)}
disabled={saveMut.isPending}
>
{#if saveMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Save
</button>
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<script lang="ts" module>
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
// 15.3 and below) return undefined here. The component handles that
// by simply showing only the "Local" sentinel in the datalist.
export function tzOptions(): string[] {
const fn = (Intl as unknown as {
supportedValuesOf?: (k: string) => string[];
}).supportedValuesOf;
if (typeof fn !== 'function') return [];
try {
return fn('timeZone');
} catch {
return [];
}
}
</script>

View File

@@ -1,249 +0,0 @@
<!--
Move/copy every photo in a heap into a folder under originals/.
Picker reuses the existing FolderTree in readonly mode; the dialog owns
the selection (`pickedPath`) so it doesn't conflict with the global
folderPath filter the sidebar drives.
Submit goes to the sidecar's POST /albums/:uid/convert. On success we
invalidate the photos / folders / heaps queries so the timeline and
sidebar refresh; if the heap was deleted and was active, route home.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
listFolders,
type HeapConvertBody,
type HeapConvertResult,
type PpAlbum,
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props {
heap: PpAlbum | null;
onClose: () => void;
}
let { heap, onClose }: Props = $props();
const qc = useQueryClient();
// Reuse the same folders cache the sidebar uses — same key so we share
// the in-flight request, and the picker invalidates it on success.
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
// Reset draft state whenever a new heap is picked (or the dialog closes
// and reopens). $effect runs after the prop change, so the form is
// blank on every fresh open.
$effect(() => {
void heap;
pickedPath = null;
mode = 'move';
subfolder = '';
deleteHeap = false;
});
const convertMut = createMutation(() => ({
mutationFn: (args: { uid: string; body: HeapConvertBody }) =>
convertHeap(args.uid, args.body),
onSuccess: (result: HeapConvertResult, vars) => {
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] });
const verb = mode === 'copy' ? 'Copied' : 'Moved';
const count = mode === 'copy' ? result.copied : result.moved;
const tail =
result.errors.length > 0
? ` · ${result.errors.length} skipped`
: '';
toast.success(`${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`);
// If the heap got deleted and we were viewing it, fall back home.
if (
result.heap_deleted &&
filters.section === 'heap' &&
filters.heapUid === vars.uid
) {
setSection('all-photos');
void goto('/', { keepFocus: true, noScroll: true });
}
onClose();
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Convert failed')
}));
function submit() {
// pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return;
// pickedPath is user-relative (listFolders strips BasePath). The
// sidecar moves files on disk so it needs a server-absolute path —
// translate before submitting.
convertMut.mutate({
uid: heap.UID,
body: {
targetFolder: toOriginalsPath(pickedPath),
mode,
subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap
}
});
}
// Copy mode doesn't change membership, so "delete heap after" is
// meaningless. Force-clear it when the user flips back to copy.
$effect(() => {
if (mode === 'copy' && deleteHeap) deleteHeap = false;
});
const open = $derived(heap !== null);
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
{mode === 'copy' ? 'Copy' : 'Move'} heap to folder
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
{heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1
? ''
: 's'}
</Dialog.Description>
</div>
</div>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/
rename their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Destination
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty
string is the sidecar's "root" sentinel — matches
resolveUnderRoot's special case in handlers_heap. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Mode + options. Plain radio + checkbox; bits-ui has dedicated
primitives but inline form controls keep the dialog small. -->
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
</div>
<label class="flex flex-col gap-1 text-[12px]">
<span class="text-muted-foreground">
New subfolder (optional)
</span>
<input
type="text"
placeholder="e.g. {heap?.Title ?? 'My heap'}"
bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-1.5 text-[12px]">
<input
type="checkbox"
bind:checked={deleteHeap}
disabled={mode === 'copy'}
/>
<span class:text-muted-foreground={mode === 'copy'}>
Delete heap after move
</span>
</label>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={onClose}
disabled={convertMut.isPending}
>
Cancel
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={pickedPath === null || convertMut.isPending}
>
{#if convertMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{mode === 'copy' ? 'Copy' : 'Move'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

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,52 +7,62 @@
import { toast } from 'svelte-sonner';
import {
aggregateKeywords,
countPhotos,
createFolder,
createHeap,
deleteFolder,
deleteHeap,
duplicateHeap,
getAllMarks,
getConfig,
getIndexSubpath,
heapDownloadUrl,
listFolderCounts,
listFolders,
listGeo,
listHeaps,
logout,
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
startIndex,
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,
type Section,
type TagCategory
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import {
isAuthenticated,
prefs,
session,
setIndexSubpathState,
userBasePath,
userLibraryBase,
toOriginalsPath,
toUserPath
} from '$lib/stores/session.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import { indexer } from '$lib/stores/indexer.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import {
ChevronRight,
Copy,
Download,
FolderInput,
@@ -62,6 +72,7 @@
LogOut,
Moon,
Pencil,
RefreshCw,
Settings,
Sun,
Trash2,
@@ -77,234 +88,59 @@
enabled: isAuthenticated()
}));
// Keyed on the effective library base (BasePath + chosen index sub-path)
// so re-rooting refetches, and so the post-bootstrap identity change forces
// a fresh fetch instead of leaving the query wedged in pending/idle (the
// old `gcTime: 0` + `enabled` toggle could strand it there on first paint).
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryKey: ['folders', userLibraryBase()],
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
staleTime: 30_000,
retry: 2,
refetchOnMount: 'always'
}));
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,
// Hydrate the per-user index sub-path into the session store on load so the
// Library tree re-roots to it without waiting for the settings dialog to be
// opened. Shares the ['prefs'] key with SettingsDialog's setter.
const prefsQuery = createQuery<string>(() => ({
queryKey: ['prefs'],
queryFn: getIndexSubpath,
enabled: isAuthenticated(),
staleTime: 60_000
staleTime: 5 * 60_000
}));
$effect(() => {
if (prefsQuery.data !== undefined) setIndexSubpathState(prefsQuery.data);
});
// 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,
queryKey: ['duplicates', userLibraryBase()],
queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(),
staleTime: 60_000
}));
// The cross-folder scan is server-scoped to the caller's effective
// library root (sidecar reads BasePath + the stored index sub-path
// itself), but the query is still keyed on userLibraryBase() so changing
// the index folder invalidates the stale, differently-scoped result.
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates,
enabled: false,
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),
@@ -342,9 +178,6 @@
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
}));
// Heap currently being converted (move/copy to folder). Setting this
// mounts <HeapConvertDialog>; the dialog clears it on close.
let convertingHeap = $state<PpAlbum | null>(null);
// Library/admin settings dialog visibility.
let settingsOpen = $state(false);
@@ -372,20 +205,35 @@
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
}
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
// above (keeping it out of `view.metadataSections`, which is reserved for
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
// doesn't grow on first paint.
const TAGS_OPEN_KEY = 'mule_tags_expanded';
let tagsExpanded = $state(loadTagsExpanded());
function loadTagsExpanded(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(TAGS_OPEN_KEY);
return raw === '1';
}
function toggleTags() {
tagsExpanded = !tagsExpanded;
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '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. 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(),
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> = {
@@ -393,27 +241,10 @@
keywords: 'Keywords',
people: 'People',
colors: 'Colors',
ratings: 'Ratings'
ratings: 'Ratings',
countries: 'Countries'
};
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}`);
}
@@ -440,6 +271,17 @@
session.user?.DisplayName?.trim() || session.user?.Name || '/'
);
// When the user has narrowed their library to an index sub-folder, the
// root row stands for that sub-folder — surface its leaf name so it's
// obvious the tree is re-rooted rather than showing the whole account.
const rootSubLabel = $derived(
prefs.indexSubpath === '' ? '' : (prefs.indexSubpath.split('/').pop() ?? '')
);
const rootTitle = $derived.by(() => {
const base = userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`;
return prefs.indexSubpath === '' ? base : `${base}${prefs.indexSubpath}`;
});
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
@@ -456,10 +298,15 @@
}
const createFolderMut = createMutation(() => ({
mutationFn: (relPath: string) => createFolder(relPath),
// The sidebar deals in user-relative paths (BasePath stripped); the
// sidecar operates on originals-relative paths. Translate on the way
// out (toOriginalsPath) and back for display (toUserPath), exactly like
// the move flow — otherwise a BasePath user's folder ops resolve to the
// wrong directory and the sidecar returns "invalid path".
mutationFn: (relPath: string) => createFolder(toOriginalsPath(relPath)),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`);
toast.success(`Folder created: ${toUserPath(r.path)}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder')
@@ -467,36 +314,59 @@
const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName),
renameFolder(toOriginalsPath(args.rel), args.newName),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
// Handler returns originals-relative paths; map back to the UI's
// user-relative space before comparing/navigating.
const oldUi = toUserPath(r.oldPath);
const newUi = toUserPath(r.newPath);
// If the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) {
setFolderPath(r.newPath);
const params = new URLSearchParams({ folder: r.newPath });
if (filters.folderPath === oldUi) {
setFolderPath(newUi);
const params = new URLSearchParams({ folder: newUi });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
toast.success(`Renamed: ${r.oldPath}${r.newPath}`);
toast.success(`Renamed: ${oldUi}${newUi}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed')
}));
const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel),
mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
const ui = toUserPath(r.path);
if (filters.folderPath && filters.folderPath.startsWith(ui)) {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}
toast.success(`Folder deleted: ${r.path}`);
toast.success(`Folder deleted: ${ui}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Delete failed')
}));
// One-click "reindex new files": kicks off a scan of the whole library
// with rescan off, so PhotoPrism only picks up files it hasn't indexed
// yet. Progress streams in via the WebSocket indexer pill, and the grid
// auto-refreshes as new tiles land (see indexer store). Guarded against
// double-trigger while a scan is already running.
async function onReindex() {
if (indexer.active) return;
const tid = toast.loading('Starting reindex…');
try {
// Scope the one-click reindex to the effective library root
// (BasePath + chosen index sub-path) rather than the whole library.
await startIndex({ path: '/' + toOriginalsPath('/'), rescan: false, cleanup: false });
toast.success('Reindex started — new files will appear as theyre found', { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
}
}
function onCreateFolder(parent: string | null = null) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return;
@@ -518,14 +388,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 +422,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).
// Tags intentionally renders 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 +437,24 @@
// 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 }
// 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.
// Ratings/Countries) and a chevron, neither of which fits the flat
// 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;
});
function isNotesActive(): boolean {
return page.url.pathname === '/notes';
}
// 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: '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);
}
},
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
];
function isRouteActive(href: string): boolean {
@@ -686,6 +528,17 @@
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</span>
<button
class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
class:opacity-0={!indexer.active}
class:opacity-100={indexer.active}
onclick={onReindex}
disabled={indexer.active}
title="Reindex new files"
aria-label="Reindex new files"
>
<RefreshCw class="h-3 w-3 {indexer.active ? 'animate-spin' : ''}" />
</button>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
@@ -720,38 +573,31 @@
{#if hasSubfolders}
<button
type="button"
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
class:text-muted-foreground={!rootActive}
onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
>
{rootExpanded ? '▾' : '▸'}
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
/>
</button>
{:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned
peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
<span class="inline-block h-[18px] w-5" 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"
class="flex min-w-0 flex-1 items-center gap-1 pl-1 text-left"
onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
title={rootTitle}
>
<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}
{#if rootSubLabel}
<span class="truncate text-muted-foreground" class:text-primary-foreground={rootActive}>
/ {rootSubLabel}
</span>
{/if}
</button>
@@ -771,8 +617,10 @@
</KebabMenu>
</div>
</div>
{#if foldersQuery.isPending}
{#if foldersQuery.isLoading}
<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 +637,7 @@
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
counts={folderCounts}
onMove={(path) => openMove({ kind: 'folder', path })}
/>
{/if}
</div>
@@ -836,16 +684,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">
@@ -872,7 +713,7 @@
</Item>
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => (convertingHeap = heap)}
onSelect={() => openMove({ kind: 'heap', heap })}
>
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder…
@@ -904,62 +745,34 @@
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@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.
-->
<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={toggleTags}
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
aria-expanded={tagsExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
<!-- Notes -->
{#if true}
{@const notesActive = isNotesActive()}
<a
href="/notes"
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={notesActive}
class:text-primary-foreground={notesActive}
class:hover:bg-primary={notesActive}
>
{tagsExpanded ? '▾' : '▸'}
</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}
{#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"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
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}
<span class="truncate">Notes</span>
</a>
{/if}
<!-- Tag categories -->
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
</a>
{/each}
</div>
<!-- Manage — curation flows that decide a photo's fate. Same
@@ -971,6 +784,33 @@
Manage
</span>
</div>
<!-- Review tabs -->
{#each reviewTabs as t (t.id)}
{@const active = isReviewTabActive(t.id)}
<a
href={`/review?tab=${t.id}`}
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<span class="truncate">{t.label}</span>
</a>
{/each}
<!-- Hidden -->
{#if true}
{@const hiddenActive = isActive('hidden')}
<button
type="button"
class="flex h-[22px] w-full items-center rounded pl-6 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}
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}
@@ -1037,7 +877,6 @@
</footer>
</div>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
<GeneralSettingsDialog
open={generalSettingsOpen}

View File

@@ -0,0 +1,311 @@
<!--
Move/copy photos into a folder under originals/ — the single dialog behind
every "move to folder" entry point (heap kebab, folder kebab, the grid's
BulkActionBar button, and the `m` shortcut). Driven by the moveDialog store
so the picker UI and the move/copy wiring live in exactly one place.
Three subjects:
• heap — move/copy an album's photos into a folder (optional subfolder,
optional delete-heap-after). The original behaviour.
• photos — move/copy a UID selection from the grid. Same options minus
delete-heap.
• folder — reparent a folder: move the directory (and its subfolders)
under a chosen destination parent. Move-only, no subfolder; the
folder keeps its own name. The picker excludes the folder
itself and its descendants.
Picker reuses the readonly FolderTree; the dialog owns the selection
(`pickedPath`) so it never fights the global folderPath filter.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Dialog } from 'bits-ui';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
movePhotosToFolder,
moveFolder,
listFolders,
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
const qc = useQueryClient();
// Reuse the same folders cache the sidebar uses — same key so we share the
// in-flight request, and the picker invalidates it on success.
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders', userLibraryBase()],
queryFn: listFolders,
enabled: isAuthenticated()
}));
const subject = $derived(moveDialog.subject);
const kind = $derived(subject?.kind);
const open = $derived(subject !== null);
// For folder reparent, exclude the folder itself and everything under it —
// you can't move a directory into its own subtree.
const folderTree = $derived.by(() => {
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
if (subject?.kind === 'folder') {
const self = subject.path;
return buildTree(paths.filter((p) => p !== self && !p.startsWith(self + '/')));
}
return buildTree(paths);
});
const showOptions = $derived(kind === 'heap' || kind === 'photos');
const showDeleteHeap = $derived(kind === 'heap');
const folderName = $derived(
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : ''
);
const headerTitle = $derived.by(() => {
if (subject?.kind === 'folder') return 'Move folder';
const verb = mode === 'copy' ? 'Copy' : 'Move';
if (subject?.kind === 'heap') return `${verb} heap to folder`;
return `${verb} photos to folder`;
});
const headerDesc = $derived.by(() => {
if (subject?.kind === 'heap') {
const n = subject.heap.PhotoCount ?? 0;
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`;
}
if (subject?.kind === 'photos') {
const n = subject.uids.length;
return `${n} photo${n === 1 ? '' : 's'} selected`;
}
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
return '';
});
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
let submitting = $state(false);
// Reset draft state whenever a new subject is picked (or the dialog closes
// and reopens), so the form is blank on every fresh open.
$effect(() => {
void subject;
pickedPath = null;
mode = 'move';
subfolder = '';
deleteHeap = false;
submitting = false;
});
// Copy mode doesn't change membership, so "delete heap after" is
// meaningless. Force-clear it when the user flips back to copy.
$effect(() => {
if (mode === 'copy' && deleteHeap) deleteHeap = false;
});
function moveSummary(verb: string, count: number, errors: number): string {
const tail = errors > 0 ? ` · ${errors} skipped` : '';
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
}
async function submit() {
const s = moveDialog.subject;
// pickedPath === '' is the root selection; distinguish it from `null`
// (nothing picked) so a falsy check doesn't wrongly block root.
if (!s || pickedPath === null || submitting) return;
submitting = true;
// Snapshot the draft before closing — closeMove() nulls the subject,
// which the reset effect uses to wipe pickedPath/mode/subfolder.
const dest = pickedPath;
const opMode = mode;
const sub = subfolder.trim() || null;
const delHeap = mode === 'move' && deleteHeap;
const labelName = folderName;
// Close the dialog immediately and run the move in the background. The
// move can be slow (a folder/heap with many files triggers a real
// disk move + reindex) and its progress surfaces in the header pill;
// keeping the modal + overlay up would hide exactly the feedback the
// user is waiting on. Mirrors the archive flow (toast + header pill).
closeMove();
const verbing = opMode === 'copy' ? 'Copying' : 'Moving';
const tid = toast.loading(`${verbing}…`);
try {
if (s.kind === 'heap') {
const r = await convertHeap(s.heap.UID, {
targetFolder: toOriginalsPath(dest),
mode: opMode,
subfolder: sub,
deleteHeap: delHeap
});
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
);
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
setSection('all-photos');
void goto('/', { keepFocus: true, noScroll: true });
}
} else if (s.kind === 'photos') {
const r = await movePhotosToFolder({
uids: s.uids,
targetFolder: toOriginalsPath(dest),
mode: opMode,
subfolder: sub
});
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
);
} else {
// Folder reparent (move only). Translate both the folder's own
// path and the destination parent to originals-relative for the
// sidecar, which moves real directories on disk.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, { id: tid });
// If we just moved the folder the timeline is showing, follow it.
if (filters.folderPath === s.path) setFolderPath(newUiPath);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
}
}
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) closeMove();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
{headerTitle}
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
{headerDesc}
</Dialog.Description>
</div>
</div>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{kind === 'folder' ? 'Destination parent' : 'Destination'}
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: drop straight into originals/ (the user's root)
without picking a subfolder. Empty string is the
sidecar's "root" sentinel. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
that keeps the folder's own name). -->
{#if showOptions}
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
</div>
<label class="flex flex-col gap-1 text-[12px]">
<span class="text-muted-foreground">New subfolder (optional)</span>
<input
type="text"
placeholder="e.g. 2024-summer"
bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
{#if showDeleteHeap}
<label class="flex items-center gap-1.5 text-[12px]">
<input type="checkbox" bind:checked={deleteHeap} disabled={mode === 'copy'} />
<span class:text-muted-foreground={mode === 'copy'}>Delete heap after move</span>
</label>
{/if}
</div>
{/if}
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={closeMove}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={pickedPath === null || submitting}
>
{#if submitting}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -9,24 +9,30 @@
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { AlertCircle, CheckCircle2, FolderOpen, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
cancelImport,
cancelIndex,
getConfig,
getErrors,
getSettings,
getIndexSubpath,
listFoldersUnderBase,
saveSettings,
startImport,
setIndexSubpath,
startIndex,
type ImportBody,
type IndexBody,
type PpFolder,
type PpLogEntry,
type PpSettings
} from '$lib/services/photoprism';
import type { PpClientConfig } from '$lib/types/photoprism';
import { userBasePath } from '$lib/stores/session.svelte';
import {
prefs,
setIndexSubpathState,
toOriginalsPath
} from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props {
open: boolean;
@@ -36,7 +42,7 @@
const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('library');
// ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them
@@ -62,7 +68,6 @@
return {
...s,
index: s.index ?? {},
import: s.import ?? {},
stack: s.stack ?? {},
download: s.download ?? {}
};
@@ -94,17 +99,68 @@
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
}
// ── Index folder (per-user, server-side) ──────────────────────────────
// The originals-relative sub-folder, under the user's BasePath, that the
// whole app re-roots to (Library tree) and the reindex scopes to. Picked
// from the *full* BasePath tree (listFoldersUnderBase) so the user can
// choose any sub-folder — including ones outside the current root. Stored
// by the sidecar; mirrored into the `prefs` store so the sidebar reacts.
const subpathFoldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders-under-base'],
queryFn: listFoldersUnderBase,
enabled: open && activeTab === 'library'
}));
const subpathTree = $derived(
buildTree((subpathFoldersQuery.data ?? []).map((f) => f.Path))
);
// Hydrate the picker selection from the server pref when the dialog opens,
// so it reflects the current choice instead of the in-memory store alone.
const indexPrefQuery = createQuery<string>(() => ({
queryKey: ['prefs'],
queryFn: getIndexSubpath,
enabled: open
}));
// Local selection: '' = whole folder. Seeded from the store, then from the
// server pref once it loads.
let pickedSubpath = $state<string>(prefs.indexSubpath);
$effect(() => {
if (open && indexPrefQuery.data !== undefined) {
pickedSubpath = indexPrefQuery.data;
}
});
const saveSubpathMut = createMutation(() => ({
mutationFn: (sub: string) => setIndexSubpath(sub),
onSuccess: (saved) => {
setIndexSubpathState(saved);
qc.setQueryData(['prefs'], saved);
// Re-root the sidebar tree + grid: both are keyed on the effective
// library base, which just changed.
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
toast.success(saved === '' ? 'Indexing whole folder' : `Index folder: ${saved}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save index folder')
}));
// ── Index tab ─────────────────────────────────────────────────────────
// Default the reindex path to the user's BasePath when scoping is on,
// so non-admins (and admins-with-BasePath) only rescan their own
// subtree. PhotoPrism's /index expects originals-relative paths with
// a leading slash; `'/'` means the whole library.
const _bp = userBasePath();
// Default the reindex path to the effective library root (BasePath +
// chosen index sub-path), so a manual run only rescans the user's working
// subtree. PhotoPrism's /index expects originals-relative paths with a
// leading slash; `'/'` means the whole library.
let indexForm = $state<IndexBody>({
path: _bp === '' ? '/' : `/${_bp}`,
path: '/' + toOriginalsPath('/'),
rescan: false,
cleanup: false
});
// SettingsDialog is mounted (open=false) before the index sub-path
// hydrates, so re-seed the manual-run path to the effective library root
// each time the dialog opens (and whenever the chosen root changes).
$effect(() => {
if (open) indexForm.path = '/' + toOriginalsPath('/');
});
const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
@@ -118,21 +174,6 @@
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
// ── Import tab ────────────────────────────────────────────────────────
let importForm = $state<ImportBody>({ path: '/', move: false, dest: '' });
const startImportMut = createMutation(() => ({
mutationFn: (b: ImportBody) => startImport(b),
onSuccess: (r) => toast.success(r.message || 'Import complete'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Import failed')
}));
const cancelImportMut = createMutation(() => ({
mutationFn: () => cancelImport(),
onSuccess: () => toast.success('Import canceled'),
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
// ── Logs tab ──────────────────────────────────────────────────────────
// Poll while the Logs tab is showing; pause otherwise so the dialog
// doesn't burn requests when the user is in another tab.
@@ -237,7 +278,7 @@
<Tabs.List
class="mb-3 flex gap-1 border-b border-border"
>
{#each ['library', 'index', 'import', 'logs', 'about'] as const as t (t)}
{#each ['library', 'index', 'logs', 'about'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
@@ -248,7 +289,73 @@
</Tabs.List>
<!-- Library — general settings -->
<Tabs.Content value="library" class="outline-none">
<Tabs.Content value="library" class="space-y-4 outline-none">
<!-- Index folder — the per-user sub-folder the Library tree
re-roots to and the reindex scopes to. Picked from the
full BasePath tree so any sub-folder is reachable. -->
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Index folder
</h3>
<p class="text-muted-foreground">
Pick the sub-folder PhotoPrism should treat as your library
root. The folder tree re-roots here and the reindex only scans
this subtree. Leave on “Whole folder” to use everything.
</p>
<div class="rounded-md border border-border bg-background p-2">
<div class="max-h-[180px] overflow-y-auto">
{#if subpathFoldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if subpathFoldersQuery.isError}
<EmptyState
size="compact"
tone="destructive"
icon={FolderOpen}
title="Could not load folders"
/>
{:else}
<!-- Whole-folder reset: '' is the "no sub-path" sentinel. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedSubpath === ''}
class:text-primary-foreground={pickedSubpath === ''}
class:hover:bg-primary={pickedSubpath === ''}
onclick={() => (pickedSubpath = '')}
>
Whole folder
</button>
{#if (subpathFoldersQuery.data ?? []).length > 0}
<FolderTree
nodes={subpathTree}
onPick={(p) => (pickedSubpath = p)}
selectedPath={pickedSubpath}
readonly
/>
{/if}
{/if}
</div>
</div>
<div class="flex items-center justify-between gap-2">
<span class="truncate text-[11px] text-muted-foreground">
Current: {prefs.indexSubpath === '' ? 'Whole folder' : prefs.indexSubpath}
</span>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => saveSubpathMut.mutate(pickedSubpath)}
disabled={saveSubpathMut.isPending || pickedSubpath === prefs.indexSubpath}
>
{#if saveSubpathMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Set index folder
</button>
</div>
</section>
<div class="h-px bg-border"></div>
{#if settingsQuery.isPending}
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
{:else if settingsQuery.isError}
@@ -284,25 +391,6 @@
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Importer defaults
</h3>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.import!.move} />
Move (instead of copy) on import
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Default destination subpath</span>
<input
type="text"
placeholder="e.g. 2026/05"
bind:value={draft.import!.dest}
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Stacks
@@ -411,34 +499,6 @@
</section>
{/if}
<!-- Features — PhotoPrism's gating bag. Render only the
keys actually present in the response (PP version
drift), labelled human-readably. -->
{#if draft.features && Object.keys(draft.features).length > 0}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Features
</h3>
<p class="text-muted-foreground">
Toggling a feature off hides it from PhotoPrism's own
UI and disables the underlying API surface.
</p>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.keys(draft.features).sort() as key (key)}
{#if typeof draft.features![key] === 'boolean'}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.features![key]}
/>
<span class="capitalize">{key}</span>
</label>
{/if}
{/each}
</div>
</section>
{/if}
</div>
<div class="mt-4 flex items-center justify-end gap-2">
@@ -511,58 +571,6 @@
</div>
</Tabs.Content>
<!-- Import — manual import run -->
<Tabs.Content value="import" class="space-y-3 text-[12px] outline-none">
<p class="text-muted-foreground">
Pulls files from the import folder into the library. With "move"
enabled, files are deleted from the import folder after a
successful import.
</p>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Source path</span>
<input
type="text"
bind:value={importForm.path}
placeholder="/"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={importForm.move} />
Move files (don't copy) after import
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Destination subpath (optional)</span>
<input
type="text"
bind:value={importForm.dest}
placeholder="e.g. 2026/05"
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
onclick={() => cancelImportMut.mutate()}
disabled={cancelImportMut.isPending || startImportMut.isPending}
>
Cancel current
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => startImportMut.mutate(importForm)}
disabled={startImportMut.isPending}
>
{#if startImportMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Start import
</button>
</div>
</Tabs.Content>
<!-- About — version, library counts, env-driven config help -->
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
{#if configQuery.isPending}

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}
<aside
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
>
<!-- 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>
{/if}
{: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,28 +131,43 @@
</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)}
poster={thumbUrl(pf.Hash, 'fit_1280')}
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
/>
{/key}
{:else}
{#key vf.Hash}
<VideoPlayer
src={videoUrl(vf.Hash)}
poster={thumbUrl(pf.Hash, 'fit_1280')}
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
/>
{/key}
{:else}
{@const altText =
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={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"
/>
{#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>
<PhotoGrid
photos={group.photos}
dimensionBadge={group.cause === 'low_resolution'}
/>
</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(() =>
patchTargets(
ids,
{ Caption: value, CaptionSrc: 'manual' },
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
)
const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
{ Caption: value, CaptionSrc: 'manual' },
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(() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
`Date → ${ids.length}`,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
: ({ TakenSrc: '' } as UpdatePhotoBody)
)
const label = `Date → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
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,35 +139,31 @@
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(() =>
patchTargets(
ids,
(p) => {
const cur = (p.Details?.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
if (cur.includes(kw)) return {};
const next = [...cur, kw].join(', ');
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
},
`Tagged "${kw}" → ${ids.length}`,
(p) => ({
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
)
const label = `Tagged "${kw}" → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
(p) => {
const cur = (p.Details?.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
if (cur.includes(kw)) return {};
const next = [...cur, kw].join(', ');
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
},
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,14 +6,16 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
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,
Globe,
HardDrive,
ImageIcon,
Loader2,
@@ -34,19 +36,21 @@
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, navigateToTag } from '$lib/stores/filters.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
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,35 @@
</span>
</div>
<!-- Location -->
<!-- Location (read-only label + jump-to-country icon). Hidden when
the photo has no resolved country. -->
<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.Country && photo.Country !== 'zz'}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
title="Open in OpenStreetMap"
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
title={`View other photos from ${countryName(photo.Country)}`}
aria-label={`View other photos from ${countryName(photo.Country)}`}
>
<ExternalLink class="h-3 w-3" />
</a>
<Globe 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 +474,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 +505,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 +584,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

@@ -3,11 +3,13 @@
import {
aggregateKeywords,
getAllMarks,
listCountries,
listLabels,
listPhotos,
listPhotosByUids,
listSubjects,
type AggregatedKeyword,
type PhotoMarksMap,
type PpCountry,
type PpLabel,
type PpSubject
} from '$lib/services/photoprism';
@@ -20,9 +22,10 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag, User } from 'lucide-svelte';
import { Globe, Hash, Tag, User } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -64,6 +67,12 @@
enabled: isAuthenticated() && category === 'people'
}));
const countriesQuery = createQuery<PpCountry[]>(() => ({
queryKey: ['countries'],
queryFn: listCountries,
enabled: isAuthenticated() && category === 'countries'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
@@ -72,12 +81,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
@@ -120,6 +134,19 @@
);
});
// PhotoPrism returns countries unsorted; sort by photo count descending so
// the most-photographed countries surface first (mirrors labels/people).
const countriesSorted = $derived(
[...(countriesQuery.data ?? [])].sort((a, b) => b.PhotoCount - a.PhotoCount)
);
const filteredCountries = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return countriesSorted;
return countriesSorted.filter((c) =>
countryName(c.Code).toLowerCase().includes(q)
);
});
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
@@ -147,9 +174,11 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const visibleCountries = $derived(filteredCountries.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
function loadMore() {
visibleCount += PAGE_SIZE;
@@ -174,6 +203,9 @@
const s = String(r);
if (selectedValue !== s) onSelect(s);
}
function pickCountry(code: string) {
if (selectedValue !== code) onSelect(code);
}
// First non-empty entry for the active category. Labels/keywords are
// already sorted by count desc, so [0] is the most-used tag; colors
@@ -198,6 +230,9 @@
const g = ratingGroups[0];
return g ? String(g.rating) : null;
}
if (category === 'countries') {
return countriesSorted[0]?.Code ?? null;
}
return null;
});
@@ -223,11 +258,16 @@
? 'People'
: category === 'colors'
? 'Colors'
: 'Ratings'
: category === 'countries'
? 'Countries'
: 'Ratings'
);
const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
</script>
@@ -443,6 +483,69 @@
{/if}
</div>
{/if}
{:else if category === 'countries'}
{#if countriesQuery.isPending}
<InlineLoader size="sm" label="Loading countries…" />
{:else if countriesQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load countries" />
{:else if filteredCountries.length === 0}
<EmptyState
size="compact"
icon={Globe}
title={filterText ? 'No countries match the filter' : 'No geotagged photos yet'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleCountries as countryRow (countryRow.Code)}
{@const active = countryRow.Code === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickCountry(countryRow.Code)}
title={countryName(countryRow.Code)}
>
{#if countryRow.Thumb}
<img
src={thumbUrl(countryRow.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded object-cover"
/>
{:else}
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-[14px]">
{countryFlag(countryRow.Code)}
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{countryName(countryRow.Code)}</span>
<span
class="flex h-4 min-w-[20px] 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'}"
>
{countryRow.PhotoCount}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreCountries,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreCountries}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredCountries.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
@@ -465,7 +568,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,15 @@
import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import {
startBulk,
setDetail,
doneBulk,
removedBulk,
failBulk,
markRemoved
} from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
@@ -50,11 +63,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,38 +122,87 @@
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;
/** Destructive removal (archive / delete): flash a red cross, then hide
* the tiles via markRemoved after the flash instead of green check. */
removing?: boolean;
}
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) {
if (bulk.removing) {
// Destructive: red-cross flash, then pull tiles from the grid.
removedBulk(bulk.doneLabel, bulk.ids);
await delay(500);
markRemoved(bulk.ids);
} else {
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'] });
void qc.invalidateQueries({ queryKey: ['marks'] });
void qc.invalidateQueries({ queryKey: ['review-groups'] });
// The optimistic-removal overlay (removedIds) is reconciled against
// the cache in +page.svelte — each id drops once the fresh, archived-
// filtered page has actually replaced it. Clearing here off this
// action's own settle raced other in-flight removals and flashed
// tiles back in.
}
}
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));
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`);
toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
} else {
toast.success(`Kept ${ids.length}`);
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);
@@ -109,16 +210,13 @@
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}`, removing: true });
}
async function onDelete() {
@@ -129,61 +227,67 @@
? '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);
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}`, removing: true });
}
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 +295,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 +312,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}
{#if isBulk}
<span class="shrink-0 text-[11px] font-medium text-foreground">
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
</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 +342,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-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 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"
@@ -255,13 +381,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 +401,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 +446,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"
@@ -328,9 +454,18 @@
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
<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={() => openMove({ kind: 'photos', uids: snapshotIds() })}
title="Move selected photos to a folder"
>
Move to folder
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">M</kbd>
</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,41 @@
{#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 === 'removed'}
<div
transition:fade={{ duration: 200 }}
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/70"
>
<X 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

@@ -24,9 +24,13 @@ export interface DuplicateGroup {
bestFileUid: string;
}
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
export async function listDuplicateGroups(basePath?: string): Promise<DuplicateGroup[]> {
// Build query: stack:true + optional path filter
const pathFilter = basePath ? ` path:${basePath}*` : '';
const q = `stack:true${pathFilter}`;
const photos = await listPhotos({
q: 'stack:true',
q,
count: 200,
merged: true,
order: 'newest'

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

@@ -7,7 +7,8 @@ import {
session,
toOriginalsPath,
toUserPath,
userBasePath
userBasePath,
userLibraryBase
} from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism';
import type {
@@ -28,6 +29,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 +45,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 +68,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 +175,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 +187,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 +309,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;
@@ -357,20 +504,45 @@ export interface PpFolder {
* row itself is dropped — the sidebar synthesises the root entry. When
* 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',
async function fetchFolders(): 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 data.folders ?? [];
}
/**
* Filter a flat folder list to those at/under `base` (server-absolute,
* originals-relative) and rewrite each `Path` to be `base`-relative, dropping
* the `base` row itself. `base === ''` (whole library) is a no-op. Sidecar
* already filters by BasePath; this is the frontend's safety net + the
* narrowing to the chosen index sub-path.
*/
function scopeFolders(folders: PpFolder[], base: string): PpFolder[] {
if (base === '') return folders;
return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
.filter((f) => f.Path === base || f.Path.startsWith(base + '/'))
.map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) }))
.filter((f) => f.Path !== '');
}
export async function listFolders(): Promise<PpFolder[]> {
// Scoped to the *effective* library root (BasePath + chosen index
// sub-path) so the sidebar tree re-roots to whatever the user picked.
return scopeFolders(await fetchFolders(), userLibraryBase());
}
/**
* Like `listFolders` but scoped to the user's *whole* BasePath, ignoring the
* chosen index sub-path. The index-folder picker uses this so the user can
* choose any sub-folder of their library as a new root — including ones
* outside the current sub-path.
*/
export async function listFoldersUnderBase(): Promise<PpFolder[]> {
return scopeFolders(await fetchFolders(), userBasePath());
}
/**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, so the count has
@@ -400,7 +572,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
>;
@@ -411,35 +583,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
return out;
}
// ── Geo ──────────────────────────────────────────────────────────────────────
// ── Countries ────────────────────────────────────────────────────────────────
export interface PpGeoFeature {
type: 'Feature';
id: string;
geometry: { type: 'Point'; coordinates: [number, number] };
properties: {
UID: string;
Hash: string;
Title?: string;
TakenAt?: string;
FavId?: number;
};
export interface PpCountry {
Code: string;
PhotoCount: number;
Thumb?: string;
}
export interface PpGeoCollection {
type: 'FeatureCollection';
features: PpGeoFeature[];
bbox?: number[];
}
export async function listGeo(q = ''): Promise<PpGeoCollection> {
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
// matching geocoded photo. MapLibre's native clustering handles 50k+
// points without breaking a sweat (PhotoPrism upstream documents
// 500k); we ask for a generous cap that covers realistic libraries.
const { data } = await http.get<PpGeoCollection>('/geo', {
params: { count: 50000, q: q || undefined }
});
export async function listCountries(): Promise<PpCountry[]> {
// Self-contained sidecar aggregation (groups photos.photo_country directly,
// no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the
// caller's BasePath the same way /labels and /counts are.
const { data } = await sidecar.get<PpCountry[]>('/api/sidecar/countries');
return data;
}
@@ -478,6 +634,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 +694,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 +731,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 +765,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 +903,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 +921,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 +967,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 +978,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 +1010,44 @@ 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>;
}
// ── Move arbitrary photos (by UID) to a folder ──────────────────────────────
// Same on-disk move/copy + reindex as convertHeap, but the sidecar resolves the
// photos from a UID list instead of an album. Backs the grid's move-to-folder.
export interface PhotosMoveBody {
uids: string[];
/** Originals-relative target folder. Empty string = originals root. */
targetFolder: string;
mode: 'move' | 'copy';
/** Optional subfolder to create under `targetFolder` and place files into. */
subfolder?: string | null;
}
export interface PhotosMoveResult {
moved: number;
copied: number;
errors: { uid: string; reason: string }[];
}
export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMoveResult> {
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
}
// ── Reparent a folder (move the directory under a different parent) ──────────
export interface FolderMoveResult {
ok: boolean;
oldPath: string;
newPath: string;
}
export async function moveFolder(rel: string, targetParent: string): Promise<FolderMoveResult> {
return callSidecar('POST', `/folders/${encodeURIComponent(rel)}/move`, {
targetParent
}) as Promise<FolderMoveResult>;
}
// ── Photo marks (rating + color) ─────────────────────────────────────────────
@@ -808,19 +1063,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;
}>;
@@ -845,26 +1100,17 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
// ── Settings / Admin ─────────────────────────────────────────────────────────
//
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer
// (Library / Index / Logs). Shapes are deliberately partial — newer
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint
// merges server-side, so it's safe to round-trip an incomplete object.
// PhotoPrism's /settings payload. muleimage only drives the indexer/stack/
// download knobs from its own UI — the `ui`/`search`/`maps`/`import`/`features`
// blocks PhotoPrism also returns only steer PhotoPrism's own SPA (which our
// users never see), so they're intentionally omitted here and never surfaced.
// The `[k: string]` index signature means an unknown round-tripped block is
// preserved on save without us having to model it.
export interface PpSettings {
ui?: {
theme?: string;
language?: string;
timeZone?: string;
startPage?: string;
scrollbar?: boolean;
zoom?: boolean;
};
search?: {
batchSize?: number;
listView?: boolean;
showTitles?: boolean;
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: {
path?: string;
convert?: boolean;
@@ -874,7 +1120,6 @@ export interface PpSettings {
skipRaw?: boolean;
skipHidden?: boolean;
};
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
name?: string;
@@ -885,42 +1130,27 @@ export interface PpSettings {
crc32?: boolean;
sha1?: boolean;
};
/**
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
* matching API endpoints) inside PP's own SPA — disabling `share` for
* example hides every share button. Optional because older PP versions
* don't return the block; the Library tab only renders toggles for
* keys it actually sees in the response.
*/
features?: {
archive?: boolean;
private?: boolean;
review?: boolean;
files?: boolean;
folders?: boolean;
moments?: boolean;
calendar?: boolean;
places?: boolean;
edit?: boolean;
share?: boolean;
library?: boolean;
import?: boolean;
logs?: boolean;
search?: boolean;
account?: boolean;
settings?: boolean;
services?: boolean;
people?: boolean;
labels?: boolean;
download?: boolean;
upload?: boolean;
delete?: boolean;
ratings?: boolean;
[k: string]: boolean | undefined;
};
[k: string]: unknown;
}
// ── Per-user prefs (sidecar) ──────────────────────────────────────────────────
//
// The index sub-path: an originals-relative folder under the user's BasePath
// that re-roots the Library tree and scopes the reindex. Stored server-side by
// the sidecar, keyed by username. Empty string = "whole folder".
export async function getIndexSubpath(): Promise<string> {
const data = (await callSidecar('GET', '/prefs')) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function setIndexSubpath(indexPath: string): Promise<string> {
const data = (await callSidecar('PUT', '/prefs', {
indexPath: indexPath.replace(/^\/+|\/+$/g, '')
})) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings');
return data;
@@ -951,26 +1181,6 @@ export async function cancelIndex(): Promise<void> {
await http.delete('/index');
}
export interface ImportBody {
path?: string;
move?: boolean;
dest?: string;
}
export async function startImport(body: ImportBody = {}): Promise<{ message: string }> {
const { data } = await http.post<{ message: string }>('/import', {
path: '/',
move: false,
dest: '',
...body
});
return data;
}
export async function cancelImport(): Promise<void> {
await http.delete('/import');
}
export interface PpLogEntry {
Time: string;
Level: string;

View File

@@ -0,0 +1,102 @@
/**
* 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
* removedBulk→ destructive completion (archive / delete): tiles flash a red cross,
* then the caller hides them via markRemoved; map 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' | 'removed'>();
/**
* 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);
}
/**
* Destructive completion (archive / permanent delete): flash a red cross on the
* target tiles instead of the green check. The caller hides the tiles via
* markRemoved shortly after the flash; this timer only cleans up the state map.
*/
export function removedBulk(label: string, ids: string[]): void {
for (const id of ids) bulkPhotoStates.set(id, 'removed');
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

@@ -17,14 +17,21 @@ export type Section =
| 'hidden'
| 'heap';
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
export type TagCategory =
| 'labels'
| 'keywords'
| 'people'
| 'colors'
| 'ratings'
| 'countries';
export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels',
'keywords',
'people',
'colors',
'ratings'
'ratings',
'countries'
] as const;
export function isTagCategory(v: unknown): v is TagCategory {
@@ -86,6 +93,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
@@ -186,6 +246,8 @@ export function filtersToQ(f: FilterState = filters): string {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'people') {
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'countries') {
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
}
}
if (f.search) parts.push(quoteIfNeeded(f.search));

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import { queryClient } from '$lib/queryClient';
import { isAuthenticated, session } from './session.svelte';
/**
@@ -44,6 +45,27 @@ let lastFileUpdateAt = 0;
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
let pendingFileName: string | undefined;
// Newly indexed photos sort newest-first, so they land at the top of the
// timeline. Refetch the photos query as files stream in so the user watches
// new tiles arrive without a manual reload — but on a much coarser cadence
// than the per-file pill throttle, since a timeline refetch is far heavier
// than a label swap. Tracked independently of `lastFileUpdateAt` so the two
// throttles don't interfere.
const PHOTOS_REFETCH_THROTTLE_MS = 2000;
let lastPhotosInvalidateAt = 0;
function invalidatePhotosGrid(): void {
if (!browser || !isAuthenticated()) return;
void queryClient.invalidateQueries({ queryKey: ['photos'] });
}
function invalidatePhotosGridThrottled(): void {
const now = Date.now();
if (now - lastPhotosInvalidateAt < PHOTOS_REFETCH_THROTTLE_MS) return;
lastPhotosInvalidateAt = now;
invalidatePhotosGrid();
}
function url(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/v1/ws`;
@@ -134,6 +156,8 @@ function handleMessage(raw: string): void {
const fileName =
(data.fileName as string | undefined) ?? (data.baseName as string | undefined);
setActiveThrottled('Indexing', fileName);
// Stream newly indexed files into the grid as the scan runs.
invalidatePhotosGridThrottled();
return;
}
case 'index.updating': {
@@ -148,6 +172,8 @@ function handleMessage(raw: string): void {
case 'index.completed': {
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
// Final refetch so the grid lands on the fully-indexed result.
invalidatePhotosGrid();
return;
}
default:

View File

@@ -0,0 +1,28 @@
/**
* Global "move to folder" dialog state. A single MoveToFolderDialog (mounted
* once in the root layout) renders whenever `subject` is non-null. Every entry
* point — heap kebab, folder kebab, the grid's BulkActionBar button, and the
* `m` keyboard shortcut — opens it through openMove(), so the picker UI and
* the move/copy logic live in exactly one place.
*/
import type { PpAlbum } from '$lib/services/photoprism';
export type MoveSubject =
| { kind: 'heap'; heap: PpAlbum }
| { kind: 'photos'; uids: string[] }
| { kind: 'folder'; path: string };
interface MoveDialogState {
subject: MoveSubject | null;
}
export const moveDialog = $state<MoveDialogState>({ subject: null });
export function openMove(subject: MoveSubject): void {
moveDialog.subject = subject;
}
export function closeMove(): void {
moveDialog.subject = null;
}

View File

@@ -57,6 +57,10 @@ export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): voi
// (Hit this with the `test` user seeing the admin's library counts
// in the left sidebar.)
queryClient.clear();
// The index sub-path is per-user; drop the prior identity's value so the
// app re-roots to the new user's whole folder until the ['prefs'] query
// rehydrates it from the sidecar.
prefs.indexSubpath = '';
session.id = resp.id;
session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
@@ -71,6 +75,7 @@ export function clearSession(): void {
session.previewToken = null;
session.downloadToken = null;
session.user = null;
prefs.indexSubpath = '';
if (browser) localStorage.removeItem(STORAGE_KEY);
// Same reasoning as adoptSession — wipe the cache so the next user
// who logs in (or the login screen itself) doesn't render with the
@@ -163,43 +168,73 @@ export function videoUrl(hash: string, format = 'avc'): string {
/**
* The signed-in user's library root, originals-relative, no leading/trailing
* slash. `""` means "whole library" — used today by admin accounts whose
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter,
* folder counts, heap convert) so each user sees only their own subtree.
* BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
* as set on their PhotoPrism account; the working library root the rest of
* the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
*/
export function userBasePath(): string {
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* Per-user "index sub-path": a folder *under* the user's BasePath that they've
* chosen as their working library root. Stored server-side by the sidecar
* (keyed by username) and hydrated into this reactive state at startup via the
* `['prefs']` query. Empty string = "whole folder" (no narrowing). Normalized
* to no leading/trailing slash.
*/
export const prefs = $state<{ indexSubpath: string }>({ indexSubpath: '' });
export function setIndexSubpathState(sub: string): void {
prefs.indexSubpath = (sub ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* The effective working library root, originals-relative, no leading/trailing
* slash: the user's BasePath narrowed by their chosen index sub-path. This is
* the single point the whole app re-roots through — `toOriginalsPath` /
* `toUserPath` (and thus the sidebar tree, timeline `path:` filter, folder
* counts, folder CRUD, reindex) all derive from it. When both are empty it's
* `""` (whole library), matching the prior BasePath-only behavior.
*/
export function userLibraryBase(): string {
const bp = userBasePath();
const sub = prefs.indexSubpath;
if (sub === '') return bp;
return bp === '' ? sub : `${bp}/${sub}`;
}
/**
* Translate a user-relative path (what the sidebar and URL deal in) to a
* server-absolute, originals-relative path (what PhotoPrism's `path:`
* operator and the sidecar's filesystem ops want).
* operator and the sidecar's filesystem ops want). Relative to the effective
* library root (`userLibraryBase()`), so the chosen index sub-path is folded
* in automatically.
*
* "" or "/" → BasePath (user's root)
* "2024/01" → "<basePath>/2024/01"
* "" or "/" → libraryBase (user's working root)
* "2024/01" → "<libraryBase>/2024/01"
* null → "" (caller decides to omit the filter entirely)
*/
export function toOriginalsPath(uiPath: string | null): string {
if (uiPath === null) return '';
const bp = userBasePath();
const base = userLibraryBase();
const rel = uiPath.replace(/^\/+|\/+$/g, '');
if (rel === '') return bp;
return bp === '' ? rel : `${bp}/${rel}`;
if (rel === '') return base;
return base === '' ? rel : `${base}/${rel}`;
}
/**
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
* are equal to the BasePath collapse to `""` (the user's root sentinel).
* Paths outside the BasePath are returned as-is, but callers should
* already have filtered those out via `listFolders`'s post-filter.
* Inverse of `toOriginalsPath` — strips the effective library-root prefix so
* the UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
* are equal to the root collapse to `""` (the user's root sentinel). Paths
* outside the root are returned as-is, but callers should already have
* filtered those out via `listFolders`'s post-filter.
*/
export function toUserPath(serverPath: string): string {
const bp = userBasePath();
const base = userLibraryBase();
const sp = serverPath.replace(/^\/+|\/+$/g, '');
if (bp === '') return sp;
if (sp === bp) return '';
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
if (base === '') return sp;
if (sp === base) return '';
if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
return sp;
}

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,29 @@
// Country code (ISO 3166-1 alpha-2, lowercase — PhotoPrism's `Country` field
// shape) → display helpers for the Countries tag-browser category.
let regionNames: Intl.DisplayNames | undefined;
function getRegionNames(): Intl.DisplayNames | undefined {
if (regionNames) return regionNames;
try {
regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
regionNames = undefined;
}
return regionNames;
}
export function countryName(code: string): string {
if (!code) return code;
const name = getRegionNames()?.of(code.toUpperCase());
return name ?? code;
}
const REGIONAL_INDICATOR_OFFSET = 0x1f1a5; // 0x1f1e6 ('A') - 'A'.charCodeAt(0)
export function countryFlag(code: string): string {
if (!code || code.length !== 2) return '';
const upper = code.toUpperCase();
return Array.from(upper)
.map((ch) => String.fromCodePoint(ch.charCodeAt(0) + REGIONAL_INDICATOR_OFFSET))
.join('');
}

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,10 +13,13 @@
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';
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
let { children } = $props();
@@ -72,6 +75,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}
@@ -114,6 +118,10 @@
helper (called by the timeline / PhotoGrid dblclick paths and
by gridKeyNav's Space handler). -->
<PreviewModal />
<!-- Single shared move-to-folder dialog, driven by the moveDialog
store. Opened from the heap/folder kebabs, the BulkActionBar
button, and the `m` shortcut — all through openMove(). -->
<MoveToFolderDialog />
{:else}
{@render children?.()}
{/if}

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, clearRemoved } 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>();
@@ -208,6 +283,20 @@
}
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
// Reconcile the optimistic-removal overlay against the actual cache.
// `removedIds` hides a tile while its photo is still present in a loaded
// page; we drop an id from the set only once it has genuinely left the
// freshly-deduped cache (i.e. every page that held it has refetched
// without it). Driving the clear from the data — rather than from each
// archive action's invalidation promise — removes the race where settling
// one action's refetch un-hid a photo that other, still-stale pages
// continued to carry, making archived tiles flash back into the grid.
$effect(() => {
const present = new Set(dedupedAll.map((p) => p.UID));
const gone = [...removedIds].filter((id) => !present.has(id));
if (gone.length) clearRemoved(gone);
});
$effect(() => {
setOrder(photos.map((p) => p.UID));
});
@@ -237,7 +326,22 @@
// Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0].
if (pages !== 1) return;
setFocused(photos[0].UID);
// 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 +734,7 @@
return;
}
emptyingArchive = true;
const tid = toast.loading("Emptying archive…");
let total = 0;
try {
while (true) {
@@ -645,9 +750,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

@@ -1,392 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import maplibregl, {
type GeoJSONSource,
type MapMouseEvent,
type MapSourceDataEvent
} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { goto } from '$app/navigation';
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
const geoQuery = createQuery<PpGeoCollection>(() => ({
queryKey: ['geo'],
queryFn: () => listGeo(),
enabled: isAuthenticated()
}));
let mapEl: HTMLDivElement | undefined = $state();
let map: maplibregl.Map | undefined;
/** Reactive flag flipped on once the MapLibre `load` event has fired
* and the `photos` source has been installed. The data-push `$effect`
* depends on this — otherwise, if the geoQuery resolves before the
* basemap style finishes loading, the effect runs with no source
* available and never re-runs (since `map` itself is not `$state`),
* leaving the map permanently empty. */
let mapReady = $state(false);
/** Markers currently attached to the map, keyed by feature id (UIDs
* for photos, `cluster:<clusterId>` for clusters). Diffed against the
* current `querySourceFeatures` set on every render to add markers
* that came into view and remove ones that scrolled out / got
* swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
* See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
const markers = new Map<string, maplibregl.Marker>();
const markersOnScreen = new Map<string, maplibregl.Marker>();
onMount(() => {
if (!mapEl) return;
map = new maplibregl.Map({
container: mapEl,
// PhotoPrism's default basemap style (CDN-hosted, no key required).
// The style JSON already references the correct glyphs URL, so
// no explicit override is needed here.
style: 'https://cdn.photoprism.app/maps/default.json',
center: [0, 20],
zoom: 1,
attributionControl: { compact: true }
});
map.addControl(
new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
'top-right'
);
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
map.on('load', () => {
addPhotoLayers();
mapReady = true;
});
// PhotoPrism's update strategy: re-reconcile markers on every map
// movement, on resize (so cluster bubbles re-balance when the
// viewport changes), on idle (catches the post-`fitBounds` settle),
// and on `sourcedata` filtered to "source fully loaded" — that's
// the moment MapLibre has processed clustering and
// `querySourceFeatures` returns meaningful results.
const onSourceData = (e: MapSourceDataEvent) => {
if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
};
map.on('sourcedata', onSourceData);
map.on('move', updateMarkers);
map.on('moveend', updateMarkers);
map.on('resize', updateMarkers);
map.on('idle', updateMarkers);
return () => {
map?.off('sourcedata', onSourceData);
map?.off('move', updateMarkers);
map?.off('moveend', updateMarkers);
map?.off('resize', updateMarkers);
map?.off('idle', updateMarkers);
markersOnScreen.forEach((m) => m.remove());
markersOnScreen.clear();
markers.clear();
map?.remove();
map = undefined;
mapReady = false;
};
});
function addPhotoLayers() {
if (!map) return;
map.addSource('photos', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
// PhotoPrism's clustering parameters — points within ~80px merge
// below zoom 17, individual photos render above that.
clusterMaxZoom: 17,
clusterRadius: 80
});
// Invisible layer for clusters — PhotoPrism does this so the source
// reports cluster features via `querySourceFeatures` (which only
// returns features actually rendered by some layer) while the
// visual presentation is owned by HTML markers below.
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'photos',
filter: ['has', 'point_count'],
paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
});
// Click an (invisible) cluster anywhere on the map → zoom to its
// expansion level. The marker DOM also has a click handler, but
// pointer-through to the map needs this as a fallback.
map.on('click', 'clusters', (e: MapMouseEvent) => {
const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0]?.properties?.cluster_id;
if (clusterId == null) return;
const source = map!.getSource('photos') as GeoJSONSource;
source.getClusterExpansionZoom(clusterId).then((zoom) => {
const geometry = features[0]?.geometry;
if (!geometry || geometry.type !== 'Point') return;
map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
});
});
}
/** Cluster bubble diameter, scaled by the number of contained photos
* — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
function clusterSize(count: number): number {
if (count >= 10000) return 74;
if (count >= 1000) return 70;
if (count >= 750) return 68;
if (count >= 200) return 66;
if (count >= 100) return 64;
return 60;
}
/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
function abbreviateCount(value: number): string {
if (value >= 1000) return `${Math.round(value / 1000)}k`;
return String(value);
}
function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
const el = document.createElement('div');
el.className = 'marker';
if (title) el.title = title;
el.style.width = '50px';
el.style.height = '50px';
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
setOrder(allUids);
setFocused(uid);
setAnchor(uid);
void goto('/');
});
return el;
}
function buildClusterMarker(clusterId: number, count: number) {
const size = clusterSize(count);
const el = document.createElement('div');
el.className = 'marker';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
const grid = document.createElement('div');
grid.className = 'cluster-marker';
el.appendChild(grid);
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = abbreviateCount(count);
el.appendChild(badge);
// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
// source is captured once here; `getClusterLeaves` returns a
// Promise, so this populates asynchronously and the bubble shows a
// dark placeholder until the thumbs arrive.
if (map) {
const source = map.getSource('photos') as GeoJSONSource | undefined;
if (source && typeof source.getClusterLeaves === 'function') {
source
.getClusterLeaves(clusterId, 4, 0)
.then((leaves) => {
const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
for (let i = 0; i < previewCount; i++) {
const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
const props = (leaf?.properties ?? {}) as { Hash?: string };
if (!props.Hash) continue;
const tile = document.createElement('div');
tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
grid.appendChild(tile);
}
})
.catch(() => {});
}
}
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (!map) return;
const source = map.getSource('photos') as GeoJSONSource;
source.getClusterExpansionZoom(clusterId).then((zoom) => {
// Use the marker's current LngLat — set just below in updateMarkers.
const m = markers.get(`cluster:${clusterId}`);
const ll = m?.getLngLat();
if (!ll) return;
map!.easeTo({ center: ll, zoom });
});
});
return el;
}
/** Reconcile HTML markers against what's currently in the rendered
* source. PhotoPrism's `updateMarkers`. */
function updateMarkers() {
if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
const features = map.querySourceFeatures('photos');
const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
const seen = new Set<string>();
for (const f of features) {
const props = (f.properties ?? {}) as Record<string, unknown> & {
cluster?: boolean;
cluster_id?: number;
point_count?: number;
UID?: string;
Hash?: string;
Title?: string;
};
const geom = f.geometry;
if (geom.type !== 'Point') continue;
const coords = geom.coordinates as [number, number];
let key: string;
let buildEl: () => HTMLElement;
if (props.cluster) {
if (props.cluster_id == null) continue;
key = `cluster:${props.cluster_id}`;
const cid = props.cluster_id;
const count = props.point_count ?? 0;
buildEl = () => buildClusterMarker(cid, count);
} else {
if (!props.UID || !props.Hash) continue;
key = props.UID;
const uid = props.UID;
const hash = props.Hash;
const title = props.Title;
buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
}
seen.add(key);
let marker = markers.get(key);
if (!marker) {
marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
markers.set(key, marker);
} else {
marker.setLngLat(coords);
}
if (!markersOnScreen.has(key)) {
marker.addTo(map);
markersOnScreen.set(key, marker);
}
}
for (const [key, marker] of markersOnScreen) {
if (!seen.has(key)) {
marker.remove();
markersOnScreen.delete(key);
}
}
}
// Push new geo data into the source whenever the query resolves AND
// the map is ready. Both orderings are handled: if data arrives first,
// the effect re-runs when `mapReady` flips; if the map is ready first,
// it re-runs when `data` arrives.
$effect(() => {
const data = geoQuery.data as
| (PpGeoCollection & { bbox?: number[] })
| undefined;
if (!map || !mapReady || !data) return;
const src = map.getSource('photos') as GeoJSONSource | undefined;
if (!src) return;
src.setData(data as GeoJSON.FeatureCollection);
// Drop stale markers; updateMarkers will rebuild for the current
// visible set on the next `sourcedata` (fired by setData) or `idle`.
markersOnScreen.forEach((m) => m.remove());
markersOnScreen.clear();
markers.clear();
// Fit to data extent on the first non-empty load — prefer the
// server-provided bbox (PhotoPrism returns one), else compute from
// the features.
if ((data.features?.length ?? 0) > 0) {
let bounds: maplibregl.LngLatBoundsLike | null = null;
if (Array.isArray(data.bbox) && data.bbox.length === 4) {
bounds = [
[data.bbox[0], data.bbox[1]],
[data.bbox[2], data.bbox[3]]
];
} else {
const b = new maplibregl.LngLatBounds();
for (const f of data.features as PpGeoFeature[]) {
const c = f.geometry.coordinates as [number, number];
if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
}
if (!b.isEmpty()) bounds = b;
}
if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
}
});
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Map
</span>
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{geoQuery.data?.features?.length ?? 0} geotagged
</span>
{/snippet}
</Toolbar>
<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>
<style>
/* PhotoPrism's marker / cluster styling, ported from
frontend/src/css/places.css. `:global` because MapLibre appends
markers outside Svelte's scoped CSS reach. */
:global(.maplibregl-map .marker) {
display: block;
border-radius: 50%;
cursor: pointer;
border: 1px solid #ffffff99;
background-color: rgba(23, 23, 23, 0.23);
background-size: cover;
background-position: center;
overflow: hidden;
position: relative;
box-shadow:
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
}
:global(.maplibregl-map .cluster-marker) {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1px;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 50%;
}
:global(.maplibregl-map .cluster-marker > div) {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
:global(.maplibregl-map .badge) {
position: absolute;
top: -5px;
right: -5px;
min-width: 24px;
height: 24px;
padding: 0 6px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #ffffff;
background: #53478a;
box-shadow:
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
}
</style>

View File

@@ -0,0 +1,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 {
@@ -31,7 +30,7 @@
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import {
@@ -86,13 +85,16 @@
// observes its cache (enabled:false) and DuplicatesView is what
// triggers the actual scan when its tab is active.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
queryKey: ['duplicates', userLibraryBase()],
queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(),
staleTime: 30_000
}));
// Scope is enforced server-side (sidecar reads the caller's BasePath +
// stored index sub-path), but key on userLibraryBase() so switching the
// index folder doesn't show a stale, differently-scoped cached result.
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000
@@ -116,7 +118,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 +140,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}
</button>
{/each}
</div>
{#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}
{/if}
{#snippet trailing()}
<div
@@ -244,7 +215,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 +238,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,
@@ -28,6 +29,7 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
@@ -68,7 +70,10 @@
// label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them).
const useServer = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
const drillQ = $derived(
useServer && selectedValue
@@ -97,10 +102,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(
@@ -156,6 +165,7 @@
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
);
}
if (category === 'countries') return countryName(selectedValue);
return selectedValue;
});
@@ -226,6 +236,7 @@
<PhotoGrid photos={drillPhotos} />
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
@@ -270,5 +281,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