64 Commits

Author SHA1 Message Date
a9685f64c4 fix(people): keep "name new faces" reachable after naming the first one
Landing on /tags/people with no value in the URL auto-selects the
first named person (so there's always something to look at) — but
that same effect made the naming workflow unreachable the moment a
second person existed to redirect into: NewFacesPanel only rendered
in the "!selectedValue" branch, and there was no way back to a null
selection once one existed.

Added a pinned "Name new faces" row in the People sidebar (with a live
unnamed-cluster count) that sets a `?view=new-faces` query param
instead of clearing the `[[value]]` route param — deliberately
independent of the value-drives-selection model so it can't be
overwritten. The auto-select-first-tag effect also needed an explicit
guard for it: navigating to a bare /tags/people URL still clears
selectedValue, which re-triggers that same effect in the same tick and
would otherwise bounce straight back to the first person before the
panel ever rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:12:56 +02:00
c6f31b5dfb feat(move): undoable moves + Lightroom-style move/copy dialog
Backend correctness was already sound (companion files travel as a
group, coordinated collision suffixes, EXDEV fallback, per-file scope
checks, blocking scoped reindex) — this pass adds reversibility and
brings the modal up to standard.

Sidecar:
- movePhotoFiles records per-file {from,to} pairs (move mode) —
  including siblings of photos that failed partway, since undo must
  restore whatever actually left its folder. Both POST /photos/move
  and POST /albums/:uid/convert return them as movedFiles.
- New POST /files/restore-moves plays those pairs backwards: both ends
  scope-checked (sources aren't quarantined like the duplicates
  restore), never clobbers an existing destination, EXDEV fallback,
  blocking reindex of affected parents so the client's refetch already
  sees the restored layout.

Dialog (all three subjects — photos, heap convert, folder reparent):
- Search field on top (autofocused) filtering the tree live: matches +
  ancestors, force-expanded without touching the sidebar's persisted
  open/collapse state (new FolderTree forceExpand prop).
- Arrow keys rove through visible rows with selection following focus
  (data-move-row attributes in FolderTree's readonly picker mode);
  Enter confirms from anywhere once a destination is set.
- Recent destinations as one-click chips (last 5, per library base).
- Live destination preview line and count-labeled confirm buttons
  ("Move 12 photos", "Move “2024”") with a disabled-reason tooltip.
- Client-side subfolder validation mirroring the sidecar's
  sanitizeFilename rules (inline error, aria-invalid, confirm gated).
- Pre-disables Move when every selected photo is already in the target.
- Undo everywhere it's safe: photo/heap moves restore via the new
  endpoint, folder moves invert to another folder move, copies stay
  toast-only (their inverse would be deletion). Success toasts carry
  an inline Undo action; ⌘Z works through the shared undo stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 09:56:21 +02:00
246d159d93 feat(metadata,people): richer metadata editing + face-naming flow
Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
  License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
  fixing videoFile(): PhotoPrism serializes MediaType as the bare word
  "video", so the old startsWith('video/') check never matched and the
  helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
  TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
  (per-photo in bulk edits) so PhotoPrism can't clobber manual date
  edits when recomputing from TimeZone, and Year/Month/Day now derive
  from local wall-clock time.

People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
  markers→files→photos SQL pass (labels pattern), replacing the
  client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
  clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
  name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
  {Name, SubjSrc:manual}, verified against PP source) which creates
  the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
  per-marker ownership checks (was blanket-forbidden, which would have
  blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
  linking to the person's page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:35:27 +02:00
a239cece10 fix(sidecar): stop blocking Authentik OIDC login on the scoped proxy
The scoped /api/v1 proxy let unauthenticated traffic through for a
guessed "oauth/" path prefix, but PhotoPrism's actual OIDC routes are
/api/v1/oidc/login and /api/v1/oidc/redirect. The Authentik callback
(oidc/redirect) has no session token yet — it IS what establishes one —
so it fell through to the authenticated branch and got rejected with
401 "invalid session" before the session existed. Since the sidecar
registers /api/v1/*rest as the catch-all for all PhotoPrism API
traffic, this broke SSO login entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 13:30:33 +02:00
75008f238a feat(review): fast keyboard-first Stacks & Duplicates resolve queue
Both tabs get a resolve-and-advance queue instead of independent
click-to-focus cards: ↑/↓ or j/k rove between groups, resolving a
group removes it optimistically and auto-advances focus, and a sticky
header tracks reclaimable bytes + a running "resolved this session"
tally. ⌘Z undoes via a new sidecar restore endpoint (gridKeyNav — the
usual ⌘Z owner — isn't mounted on these tabs, so DuplicatesView wires
its own).

Sidecar (handlers_dups.go, fs.go, main.go):
- POST /duplicates/restore — inverse of /duplicates/archive, moves
  quarantined files back to their original path with the same BasePath
  guards and async reindex-with-cleanup.
- Scan results now include each file's mtime so the UI can label
  older/newer copies.

Stack losers now go through the same sidecar quarantine as
cross-folder duplicates (setPrimary + archiveDuplicatePaths) instead
of a hard PhotoPrism DELETE, so both tabs share one recoverable,
undoable resolution path (services/duplicateActions.svelte.ts).

StackGroupCard: comparison-first — fact rows highlight the best
size/resolution per file, a "Suggested" badge appears when one file
wins outright, and Space opens a fullscreen CompareLightbox that flips
between candidates while preserving zoom/pan (extracted the zoom/pan
gesture handling from PreviewPane into a shared lib/actions/zoomPan.ts
action so both consumers share one implementation).

CrossFolderGroupCard: since every copy is byte-identical, the old grid
of N identical thumbnails told the user nothing — replaced with one
thumbnail plus a path list that highlights the differing folder
segment and flags the indexed/newest copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 11:00:36 +02:00
0f65bfb94a feat(web): native favorites, lightbox zoom, EXIF jump links + perf fix
- Favorites use PhotoPrism's own like/unlike endpoint (not a mule-only
  mark) so they sync to third-party gallery apps, with heart controls
  in the tile, sidebar, and an `f` shortcut.
- Lightbox: wheel-zoom around cursor, double-click to 2.5x, drag-to-pan,
  auto-upgrades to the fit_2048 tile past 1.25x zoom.
- Sidebar: copy-EXIF button, clickable Camera/Lens values that jump to
  a filtered timeline (camera:/lens: DSL), matching the existing
  Country link.
- Fix filtersToQ() quoting the entire search string whenever it
  contained a colon, which silently turned any raw DSL operator
  (camera:, taken:2024, etc.) into a literal phrase search — discovered
  while verifying the new jump-links against production.
- Disable TanStack Query's refetchOnWindowFocus: the indexer WebSocket
  already invalidates photo queries on real changes, so the focus
  refetch was just a redundant full-timeline re-render on tab-switch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:56:49 +02:00
9ba8d625bc feat(web): ⌘K command palette
bits-ui Command in a dialog: jump to sections, heaps, folders (from the
already-warm sidebar queries), plus dark-mode and shortcut-overlay
actions. Global ⌘K binding in the layout works from any route and while
inputs hold focus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:22:31 +02:00
4f04c1f7b0 feat(web): toolbar filter chips (type/year/favorites) + timeline sort control
Chips compile into the existing q-DSL alongside search/folder/section
terms and persist to the URL. Sort (newest/oldest/added/name) threads
through the sidecar timeline's order param; deep-link anchor windows
stay newest-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:19:47 +02:00
9ef1b4c2f9 feat(web): keyboard rating/color marks, search focus, shortcuts overlay
Lightroom-style keys in grid+preview: 0-5 rating (re-key toggles),
6-9 color labels, optimistic marks cache patch with rollback. / focuses
the search box, ? opens a new shortcut-reference overlay that inertly
swallows other keys while open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:15:00 +02:00
ac7d0ac2eb docs: mobile & third-party app setup with per-user scoping
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:10:59 +02:00
312a4c1ee4 feat(sidecar): extend scoped proxy to web-client mutations, harden path classification
Batch archive/restore/delete/approve/private validate every UID against
the PhotoPrism DB in one query. Per-photo PUT/approve/like/stack-file
ops are ownership-checked. Admin-role sessions pass through fully so
settings/users/index dialogs keep working. Paths are unescaped+cleaned
before classification so encoded dot-segments can't smuggle past the
allowlist. Full httptest coverage of the routing decisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:05:12 +02:00
e578e1ce75 feat(sidecar): scoped PhotoPrism-compatible API proxy for third-party apps
PhotoPrism CE doesn't enforce auth_users.base_path on API reads (any
user can q=path:"other/*"). New /api/v1/* proxy forwards to PhotoPrism
with per-session enforcement: search queries get their path filter
validated/injected, single-photo reads and like are ownership-checked,
hash-addressed media and session/config pass through, everything else
is 403 for scoped users. Admins (empty BasePath) pass through fully.
prism.hubris.network will route here instead of straight to PhotoPrism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:59:19 +02:00
6cbabda86b feat(sidecar): enforce per-user BasePath on all filesystem mutations
Folder create/rename/delete/move, photo move, heap convert, and file
rename now reject paths outside the caller's BasePath (403). Sources
resolved via PhotoPrism UIDs are re-checked in movePhotoFiles. The
USER_BASEPATHS reconciler also sets upload_path so client-app uploads
land inside the user's subtree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:46:00 +02:00
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
a7b8a60473 web: admin surfaces so the PhotoPrism UI is never needed
- Account tab in General settings — self-service password change.
- UsersDialog (admin-only footer entry) — full /api/v1/users CRUD with
  admin-issued password reset.
- People as a fifth tag category alongside Labels/Keywords/Colors/Ratings,
  backed by /api/v1/subjects and the `person:` DSL clause.
- About tab in Library settings — version, library counts, feature chips,
  and a collapsible env-config help panel for the bits PP has no runtime
  API for (OIDC, TF, WebDAV).
- Library tab expanded with Indexer-advanced, extra Downloads checksums,
  and a Features grid that only renders keys PhotoPrism actually returns.
- Fix the SettingsDialog null-draft race the same way GeneralSettingsDialog
  already had: normalize on open, never null on close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
e364e4128f web: unify empty + loading states behind EmptyState/InlineLoader
Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
92 changed files with 10385 additions and 3338 deletions

View File

@@ -65,6 +65,19 @@ PP_GID=1000
# OIDC_ROLE=user # 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 ────────────────────────────────────────────────────────────────── # ── LOGGING ──────────────────────────────────────────────────────────────────
PP_LOG_LEVEL=info 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

@@ -102,6 +102,35 @@ labels still work; the following sidecar endpoints return an OS error:
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
`PP_READONLY` and gates its own backwrite / import paths. `PP_READONLY` and gates its own backwrite / import paths.
## Mobile & third-party apps (per-user)
PhotoPrism CE does **not** enforce `auth_users.base_path` on API reads —
any authenticated user can search the whole library. The sidecar
therefore ships a scoping proxy at `/api/v1/*` (see
[`sidecar/handlers_ppproxy.go`](sidecar/handlers_ppproxy.go)) and the
reverse proxy routes the public `/api/v1` there instead of straight to
PhotoPrism. Result: any PhotoPrism-compatible app pointed at the site
sees only the logged-in user's photos.
- **Server URL for apps**: the site itself (e.g.
`https://photos.hubris.network`). Known-good client:
[Gallery for PhotoPrism](https://github.com/Radiokot/photoprism-android-client)
(Android/F-Droid).
- **Login**: the user's normal username/password. For OIDC accounts (no
password), mint an app password:
`docker exec pp-app photoprism auth add -n "gallery" -s "*" <username>`
and use it as the password in the app.
- **What's scoped**: photo/geo searches, per-photo reads and edits,
batch operations, downloads by UID. Hash-addressed media (thumbnails,
video streams, file downloads) is token-guarded and passes through.
- **What's shared** (CE has no per-user variants of these): album
*names*, labels, and people — the photos inside them stay scoped.
Album zip downloads are generated by PhotoPrism and are not scoped.
- **Uploads**: the reconciler mirrors `base_path` into `upload_path`,
so WebDAV/app uploads land inside the user's own subtree.
- Sessions with the `admin` role bypass the proxy scoping entirely (the
web client's settings/users/index dialogs need the raw API).
## Dev iteration loop ## Dev iteration loop
For fast iteration on the sidecar without rebuilding its image on every For fast iteration on the sidecar without rebuilding its image on every
@@ -115,6 +144,7 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
. .
├── docker-compose.yml base stack: mariadb + photoprism + sidecar ├── docker-compose.yml base stack: mariadb + photoprism + sidecar
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping) ├── 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) ├── .env.example required env vars (copy to .env)
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user ├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
├── pp/ PhotoPrism bind-mounted state (storage, import) ├── pp/ PhotoPrism bind-mounted state (storage, import)
@@ -122,4 +152,18 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
└── web/ SvelteKit frontend └── 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/ [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. # silently no-op on Debian/Ubuntu and macOS Docker Desktop.
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z - ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "/usr/bin/mariadb-admin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 12 retries: 12

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"net/http" "net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -11,7 +13,7 @@ import (
// is the only authority, and we probe PhotoPrism with it before doing any // is the only authority, and we probe PhotoPrism with it before doing any
// destructive work. The handler reads the validated token off the context // destructive work. The handler reads the validated token off the context
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual // 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 { func requireSession(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token := c.GetHeader("X-Auth-Token") token := c.GetHeader("X-Auth-Token")
@@ -19,11 +21,15 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
return 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"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return return
} }
c.Set("token", token) c.Set("token", token)
c.Set("userName", user.UserName)
c.Set("userUID", user.UserUID)
c.Set("basePath", user.BasePath)
c.Next() c.Next()
} }
} }
@@ -42,3 +48,74 @@ func ctxToken(c *gin.Context) string {
} }
return s 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
}
// userScopeRoot returns the absolute directory the session may mutate:
// ORIGINALS_ROOT/<BasePath> for scoped users, the whole originals root for
// admins (empty BasePath). Lexical join only — callers compare it against
// paths built the same way from cfg.OriginalsRoot.
func userScopeRoot(c *gin.Context, cfg *Config) string {
base := strings.Trim(ctxBasePath(c), "/")
if base == "" {
return cfg.OriginalsRoot
}
return filepath.Join(cfg.OriginalsRoot, base)
}
// requireUserScope guards an already-root-resolved absolute path against
// the caller's BasePath. PhotoPrism scopes what a session can *see* by
// BasePath, but the sidecar's filesystem endpoints accept raw paths, so
// every mutation must re-check that boundary here. `strict` additionally
// rejects the scope root itself — renaming/deleting/moving the user's own
// base folder would detach their library from auth_users.base_path.
// Writes the 403 response and returns false when out of bounds.
func requireUserScope(c *gin.Context, cfg *Config, abs string, strict bool) bool {
scope := userScopeRoot(c, cfg)
if !sameOrUnder(abs, scope) {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
return false
}
if strict && abs == scope {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify your library root"})
return false
}
return true
}

50
sidecar/auth_test.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func scopeCtx(basePath string) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("basePath", basePath)
return c
}
func TestRequireUserScope(t *testing.T) {
cfg := &Config{OriginalsRoot: filepath.FromSlash("/originals")}
abs := func(rel string) string { return filepath.Join(cfg.OriginalsRoot, filepath.FromSlash(rel)) }
cases := []struct {
name string
basePath string
path string
strict bool
want bool
}{
{"admin sees root", "", cfg.OriginalsRoot, false, true},
{"admin anywhere", "", abs("bob/x"), false, true},
{"admin strict rejects root", "", cfg.OriginalsRoot, true, false},
{"scoped inside own tree", "alice", abs("alice/2024"), false, true},
{"scoped own root non-strict", "alice", abs("alice"), false, true},
{"scoped own root strict", "alice", abs("alice"), true, false},
{"scoped other user", "alice", abs("bob/2024"), false, false},
{"scoped sibling prefix", "alice", abs("alice2/2024"), false, false},
{"scoped originals root", "alice", cfg.OriginalsRoot, false, false},
{"nested base path", "family/alice", abs("family/alice/x"), false, true},
{"nested base path parent", "family/alice", abs("family"), false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := scopeCtx(tc.basePath)
if got := requireUserScope(c, cfg, tc.path, tc.strict); got != tc.want {
t.Errorf("requireUserScope(base=%q, path=%q, strict=%v) = %v, want %v",
tc.basePath, tc.path, tc.strict, got, tc.want)
}
})
}
}

View File

@@ -9,13 +9,13 @@ import (
) )
// Mark mirrors the per-photo extras the web client stores via the marks // Mark mirrors the per-photo extras the web client stores via the marks
// endpoints — rating + four-colour label. PhotoUID is the row key; both // endpoints — rating + four-colour label. Composite primary key
// payload fields are nullable so the sparse "no rating / no colour" state // (photo_uid, user_name) so each user has independent marks. Both payload
// round-trips cleanly. The Node prototype kept this in a JSON file; we // fields are nullable so the sparse "no rating / no colour" state
// migrate to MariaDB here so the M4 sharing work has a real table to // round-trips cleanly.
// extend.
type Mark struct { type Mark struct {
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"` 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"` Rating *int `gorm:"column:rating" json:"rating,omitempty"`
Color *string `gorm:"size:16;column:color" json:"color,omitempty"` Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
@@ -26,6 +26,21 @@ type Mark struct {
// nothing surprising lands in the schema. // nothing surprising lands in the schema.
func (Mark) TableName() string { return "marks" } 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 // asJSON returns the wire shape clients expect — same flat object the
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders // Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
// as `{}` which the client treats as "no mark on this photo". // 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 { if err != nil {
return nil, err return nil, err
} }
if err := db.AutoMigrate(&Mark{}); err != nil { if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil {
return nil, err return nil, err
} }
return db, nil return db, nil

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// sanitizeFilename trims a user-supplied filename and rejects anything // sanitizeFilename trims a user-supplied filename and rejects anything
@@ -108,6 +109,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
return "", "", false 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. // itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string { func itoa(n int) string {
if n == 0 { if n == 0 {
@@ -137,6 +169,7 @@ type fileEntry struct {
RelPath string RelPath string
AbsPath string AbsPath string
Size int64 Size int64
ModTime time.Time
} }
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism // supportedExts mirrors the Node prototype's whitelist. PhotoPrism
@@ -191,6 +224,7 @@ func walkFiles(root string) ([]fileEntry, error) {
RelPath: rel, RelPath: rel,
AbsPath: p, AbsPath: p,
Size: info.Size(), Size: info.Size(),
ModTime: info.ModTime(),
}) })
return nil return nil
}) })

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" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
const quarantineDir = ".duplicates" const quarantineDir = ".duplicates"
@@ -20,13 +22,16 @@ const quarantineDir = ".duplicates"
type dupFileLite struct { type dupFileLite struct {
Path string `json:"path"` Path string `json:"path"`
Size int64 `json:"size"` Size int64 `json:"size"`
// RFC3339 mtime so the UI can label older/newer copies. Copies are
// byte-identical, so mtime is the only per-copy signal besides path.
ModTime string `json:"modTime,omitempty"`
} }
type dupGroup struct { type dupGroup struct {
Hash string `json:"hash"` Hash string `json:"hash"`
Size int64 `json:"size"` Size int64 `json:"size"`
IndexedPath *string `json:"indexedPath"` IndexedPath *string `json:"indexedPath"`
Files []dupFileLite `json:"files"` Files []dupFileLite `json:"files"`
} }
// dupListPhoto is the partial photo shape we pull from PhotoPrism when // dupListPhoto is the partial photo shape we pull from PhotoPrism when
@@ -36,17 +41,41 @@ type dupListPhoto struct {
Files []ppFile `json:"Files"` 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) { return func(c *gin.Context) {
token := ctxToken(c) token := ctxToken(c)
start := time.Now() 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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, // Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip // so size-collision is a cheap O(N) prefilter that lets us skip
@@ -102,7 +131,11 @@ func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
} }
g := dupGroup{Hash: h, Size: hashSize[h]} g := dupGroup{Hash: h, Size: hashSize[h]}
for _, f := range files { for _, f := range files {
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size}) g.Files = append(g.Files, dupFileLite{
Path: f.RelPath,
Size: f.Size,
ModTime: f.ModTime.UTC().Format(time.RFC3339),
})
} }
// Best-effort lookup; swallow errors. The hash query is cheap on // Best-effort lookup; swallow errors. The hash query is cheap on
// PhotoPrism's side (indexed column). // PhotoPrism's side (indexed column).
@@ -149,7 +182,7 @@ type dupArchiveErr struct {
Error string `json:"error"` 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) { return func(c *gin.Context) {
token := ctxToken(c) token := ctxToken(c)
var body dupArchiveBody var body dupArchiveBody
@@ -158,6 +191,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return 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 // Each archive batch lands in its own timestamped subdir so the
// user can browse what was quarantined when (and recover by hand // user can browse what was quarantined when (and recover by hand
// if they change their mind). // if they change their mind).
@@ -228,3 +278,95 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs}) c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
} }
} }
type dupRestoreBody struct {
// Moves mirror the archive response's {from,to} pairs verbatim; the
// handler renames each `to` (quarantine path) back to its `from`.
Moves []dupMoved `json:"moves"`
}
// handleDupRestore is the inverse of handleDupArchive: it moves files
// out of `.duplicates/<ts>/` back to their original paths. It exists so
// the web client can offer real undo for duplicate/stack resolution —
// quarantine is only trustworthy if backing out is one keystroke.
func handleDupRestore(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupRestoreBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
return
}
// Authz mirrors handleDupArchive: every destination (`from`) must
// live under the caller's effective library root, and every source
// (`to`) must live inside the quarantine dir — otherwise this
// endpoint would double as an arbitrary-move tool.
root := effectiveLibraryRoot(c, db)
for _, m := range body.Moves {
src := strings.Trim(m.To, "/")
if src != quarantineDir && !strings.HasPrefix(src, quarantineDir+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "source not in quarantine"})
return
}
if root != "" {
dst := strings.Trim(m.From, "/")
if dst != root && !strings.HasPrefix(dst, root+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
return
}
}
}
restoreOne := func(m dupMoved) error {
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
if err != nil {
return errors.New("invalid quarantine path")
}
// The destination must not exist yet — mustExist=false resolves
// the path without requiring it on disk, and the Stat below
// refuses to clobber anything that reappeared in the meantime.
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
if err != nil {
return errors.New("invalid destination path")
}
if _, err := os.Stat(dstAbs); err == nil {
return errors.New("destination already exists")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return err
}
if err := os.Rename(srcAbs, dstAbs); err != nil {
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
return err
}
if err2 := os.Remove(srcAbs); err2 != nil {
return errors.New("restored but quarantine copy remove failed: " + err2.Error())
}
}
return nil
}
restored := []dupMoved{}
errs := []dupArchiveErr{}
for _, m := range body.Moves {
if err := restoreOne(m); err != nil {
errs = append(errs, dupArchiveErr{Path: m.To, Error: err.Error()})
continue
}
restored = append(restored, dupMoved{From: m.To, To: m.From})
slog.Info("dup.restore", "from", m.To, "to", m.From)
}
if len(restored) > 0 {
go func() {
if err := pp.reindex(context.Background(), token, "/"); err != nil {
slog.Warn("dup.restore reindex failed", "err", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
}
}

View File

@@ -47,6 +47,9 @@ func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return return
} }
if !requireUserScope(c, cfg, abs, true) {
return
}
if _, err := os.Stat(abs); err == nil { if _, err := os.Stat(abs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
return return
@@ -92,6 +95,9 @@ func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return return
} }
if !requireUserScope(c, cfg, oldAbs, true) {
return
}
st, err := os.Stat(oldAbs) st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() { if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"}) c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
@@ -142,6 +148,9 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"}) c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
return return
} }
if !requireUserScope(c, cfg, abs, true) {
return
}
st, err := os.Stat(abs) st, err := os.Stat(abs)
if err != nil || !st.IsDir() { if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"}) c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})

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,20 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
subfolder = s subfolder = s
} }
// Resolve destination. resolveUnderRoot ensures the target lives // Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
// inside ORIGINALS_ROOT and that its parent is a real directory. // "drop these into originals/ itself" (the modal's "Root" option).
// Empty / "/" / "." are valid here — they mean "drop these into targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
// originals/ itself" (the modal's "Root" option). resolveUnderRoot if err != nil {
// rejects those for safety, so handle the root case explicitly. c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
var targetAbs string return
trimmed := strings.Trim(body.TargetFolder, "/")
if trimmed == "" || trimmed == "." {
targetAbs = cfg.OriginalsRoot
} else {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
targetAbs = abs
} }
destAbs := targetAbs if !requireUserScope(c, cfg, targetAbs, false) {
if subfolder != "" { return
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 membership via the q=album:UID query (count=1000
// Pull the heap's photos via the q=album:UID query. count=1000 covers // covers every realistic heap). We only need the UID list here — the
// every realistic heap; merged=true expands stacked variants so we // search's Files array is trimmed and drops videos, so we re-resolve
// move the JPG/HEIC sibling alongside the primary. // each photo's full file set below via resolvePhotosFull.
q := url.QueryEscape("album:" + albumUID) q := url.QueryEscape("album:" + albumUID)
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true" listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil) resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
@@ -123,114 +108,31 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(resp.Status, gin.H{"error": "list photos failed"}) c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return return
} }
var photos []heapPhoto var listed []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil { if err := json.Unmarshal(resp.Body, &listed); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"}) c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return return
} }
uids := make([]string, 0, len(listed))
sourceParents := map[string]struct{}{} for _, p := range listed {
errs := []heapErr{} uids = append(uids, p.UID)
moved, copied := 0, 0
for _, photo := range photos {
// Pick the file to physically move. PhotoPrism's "primary" file
// for a HEIC photo is the generated `.HEIC.jpg` preview that
// lives in storage/sidecar (Root=="sidecar"), not in originals
// — moving that path would fail "file missing on disk" every
// time. Prefer the primary that lives in originals (Root=="/")
// and fall back to the first originals-rooted file. PhotoPrism
// regenerates sidecars on reindex, so they don't need to follow.
var file ppFile
found := false
for _, f := range photo.Files {
if f.Root == "/" && f.Primary {
file, found = f, true
break
}
}
if !found {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue
}
srcRel := file.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
continue
}
st, err := os.Stat(srcAbs)
if err != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if err := os.Rename(srcAbs, dstAbs); err != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
if err2 := os.Remove(srcAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
continue
}
}
moved++
} else {
if err := copyFile(srcAbs, dstAbs); err != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
copied++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
} }
// Reindex the destination + every source parent so PhotoPrism's // Re-fetch each photo's complete file list so videos (and other multi-
// DB catches up. We block on these so the response only goes out // file photos) move whole — the album search alone would orphan the
// after the index reflects the move — callers (the frontend's // .mov. See resolvePhotosFull.
// invalidateQueries refetch in particular) need the next /photos photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, uids)
// fetch to return the moved files, otherwise the folder view if err != nil {
// looks unchanged. PhotoPrism's index endpoint serialises calls c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
// internally; running them sequentially matches that contract return
// without surprising the server.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
} }
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs) moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
paths[parent] = struct{}{} if err != nil {
} c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
for p := range paths { return
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
} }
errs = append(resolveErrs, errs...)
heapDeleted := false heapDeleted := false
if deleteHeap { if deleteHeap {
@@ -255,8 +157,198 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"moved": moved, "moved": moved,
"copied": copied, "copied": copied,
"movedFiles": movedPairs,
"errors": errs, "errors": errs,
"heap_deleted": heapDeleted, "heap_deleted": heapDeleted,
}) })
} }
} }
// 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).
// `scopeAbs` is the caller's userScopeRoot — source files outside it fail
// per-photo, so a UID that resolves outside the user's BasePath (however
// PhotoPrism came to return it) can't be used to pull files across users.
//
// `movedPairs` records every file that physically moved (move mode only —
// copies have no inverse pair) as originals-relative {from,to}, including
// siblings of photos that later failed partway: undo must restore whatever
// actually left its folder, not just fully-successful photos.
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, movedPairs []dupMoved, 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, nil, e
}
}
sourceParents := map[string]struct{}{}
errs = []heapErr{}
movedPairs = []dupMoved{}
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, scopeAbs) {
failure = "path outside your library"
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
}
}
if dstRel, relErr := filepath.Rel(cfg.OriginalsRoot, dstAbs); relErr == nil {
movedPairs = append(movedPairs, dupMoved{From: srcRel, To: dstRel})
}
} 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, movedPairs, 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" "gorm.io/gorm"
) )
// validColors is the four-color palette mule-image always shipped. The // validColors is the color palette the web client offers (COLOR_SWATCHES in
// empty string is the explicit "clear color" sentinel. // 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{}{ var validColors = map[string]struct{}{
"red": {}, "red": {},
"orange": {}, "orange": {},
"yellow": {}, "yellow": {},
"green": {}, "green": {},
"teal": {},
"blue": {},
"purple": {},
"pink": {},
} }
// markPatch is the request body for all three mutating mark endpoints. // 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 != "") 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 // `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start. // 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 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 return nil, err
} }
out := make(map[string]map[string]any, len(rows)) 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 { func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
marks, err := allMarksJSON(db) marks, err := allMarksJSON(db, ctxUserName(c))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -100,7 +105,7 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("uid") uid := c.Param("uid")
var m Mark 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{}) c.JSON(http.StatusOK, gin.H{})
return return
@@ -115,18 +120,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
// upsert applies the patch and writes back. Returns the resulting JSON // upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted). // 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 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) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
m.PhotoUID = uid m.PhotoUID = uid
m.UserName = userName
keep := patch.apply(&m) keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC() m.UpdatedAt = time.Now().UTC()
if !keep { if !keep {
// Drop the row entirely so a re-fetch returns {}. if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
return nil, err return nil, err
} }
return map[string]any{}, nil 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
out, err := upsert(db, uid, &patch) out, err := upsert(db, uid, ctxUserName(c), &patch)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -178,6 +183,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
userName := ctxUserName(c)
applied := make(map[string]map[string]any, len(body.IDs)) applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client // Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp. // expects atomic semantics for a bulk star/colour stamp.
@@ -186,7 +192,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
if uid == "" { if uid == "" {
continue continue
} }
out, err := upsert(tx, uid, &body.Patch) out, err := upsert(tx, uid, userName, &body.Patch)
if err != nil { if err != nil {
return err return err
} }

299
sidecar/handlers_move.go Normal file
View File

@@ -0,0 +1,299 @@
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
}
if !requireUserScope(c, cfg, targetAbs, false) {
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, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
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,
"movedFiles": movedPairs,
"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
}
if !requireUserScope(c, cfg, oldAbs, true) {
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
}
if !requireUserScope(c, cfg, targetParentAbs, false) {
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,
})
}
}
type restoreMovesBody struct {
// Moves mirror the movedFiles pairs from photos-move / heap-convert
// responses verbatim; the handler renames each `to` (current location)
// back to its `from` (original location).
Moves []dupMoved `json:"moves"`
}
// handleRestoreMoves is the generic inverse of movePhotoFiles: it moves
// files back to where they came from, powering ⌘Z undo for photo/heap
// moves. Unlike the duplicates restore (whose sources must live in the
// .duplicates/ quarantine), both ends here are arbitrary library paths —
// so BOTH are validated against the caller's scope, and existing
// destinations are never clobbered.
//
// Route: POST /api/sidecar/files/restore-moves (behind requireSession)
func handleRestoreMoves(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body restoreMovesBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
return
}
scope := userScopeRoot(c, cfg)
type resolved struct {
srcAbs, dstAbs string
srcRel, dstRel string
}
items := make([]resolved, 0, len(body.Moves))
for _, m := range body.Moves {
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid source path: " + m.To})
return
}
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid destination path: " + m.From})
return
}
if !sameOrUnder(srcAbs, scope) || !sameOrUnder(dstAbs, scope) {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
return
}
items = append(items, resolved{srcAbs: srcAbs, dstAbs: dstAbs, srcRel: m.To, dstRel: m.From})
}
restored := []dupMoved{}
errs := []dupArchiveErr{}
parents := map[string]struct{}{}
for _, it := range items {
if _, err := os.Stat(it.dstAbs); err == nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "destination already exists"})
continue
} else if !os.IsNotExist(err) {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
continue
}
if err := os.MkdirAll(filepath.Dir(it.dstAbs), 0o755); err != nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
continue
}
if err := os.Rename(it.srcAbs, it.dstAbs); err != nil {
if err2 := copyFile(it.srcAbs, it.dstAbs); err2 != nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
continue
}
if err2 := os.Remove(it.srcAbs); err2 != nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "restored but source remove failed: " + err2.Error()})
continue
}
}
restored = append(restored, dupMoved{From: it.srcRel, To: it.dstRel})
parents[filepath.Dir(it.srcRel)] = struct{}{}
parents[filepath.Dir(it.dstRel)] = struct{}{}
slog.Info("files.restore-moves", "from", it.srcRel, "to", it.dstRel)
}
// Block on the reindex like movePhotoFiles does — the client
// invalidates its photo queries right after this returns, and the
// refetch must already see the restored locations.
for p := range parents {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
}
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
}
}

178
sidecar/handlers_people.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// People (subjects + unnamed face clusters), scoped to the caller's
// BasePath the same way handleLabels scopes labels: proxy PhotoPrism's
// list, then one SQL pass over the caller's slice of the library to
// recompute counts and drop entries with nothing in scope.
//
// PhotoPrism CE treats subjects and face clusters as library-wide
// metadata (like albums and labels) — scoping here controls what each
// user *sees*, while the underlying entities stay shared.
// PpSubjectLite mirrors the fields the web client consumes from
// PhotoPrism's /api/v1/subjects rows.
type PpSubjectLite struct {
UID string `json:"UID"`
Type string `json:"Type"`
Slug string `json:"Slug"`
Name string `json:"Name"`
Alias string `json:"Alias"`
Favorite bool `json:"Favorite"`
Private bool `json:"Private"`
Excluded bool `json:"Excluded"`
Hidden bool `json:"Hidden"`
PhotoCount int `json:"PhotoCount"`
FileCount int `json:"FileCount"`
Thumb string `json:"Thumb"`
}
// handleSubjects proxies PhotoPrism's /api/v1/subjects and post-filters
// per-subject photo counts to the caller's BasePath, dropping subjects
// whose faces never appear in the caller's photos.
//
// Route: GET /api/sidecar/subjects (behind requireSession)
func handleSubjects(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
query := c.Request.URL.RawQuery
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/subjects?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream subjects request failed"})
return
}
var subjects []PpSubjectLite
if err := json.Unmarshal(resp.Body, &subjects); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse subjects"})
return
}
if basePath == "" || ppDb == nil {
c.JSON(http.StatusOK, subjects)
return
}
// One query: per-subject photo count + a representative face-crop
// thumb, restricted to the caller's path subtree. The join chain is
// markers → files → photos, matching how PhotoPrism binds a face to
// a picture.
type subjStat struct {
SubjUID string `gorm:"column:subj_uid"`
Cnt int64 `gorm:"column:cnt"`
Thumb string `gorm:"column:thumb"`
}
var stats []subjStat
if err := ppDb.Raw(`
SELECT m.subj_uid AS subj_uid,
COUNT(DISTINCT p.id) AS cnt,
SUBSTRING_INDEX(GROUP_CONCAT(m.thumb ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS thumb
FROM markers m
JOIN files f ON f.file_uid = m.file_uid AND f.file_missing = 0
JOIN photos p ON p.photo_uid = f.photo_uid AND p.deleted_at IS NULL
WHERE m.marker_type = 'face'
AND m.marker_invalid = 0
AND m.subj_uid IS NOT NULL AND m.subj_uid <> ''
AND (p.photo_path = ? OR p.photo_path LIKE ?)
GROUP BY m.subj_uid
`, basePath, basePath+"/%").Scan(&stats).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "subject stats query failed"})
return
}
cntMap := make(map[string]int64, len(stats))
thumbMap := make(map[string]string, len(stats))
for _, s := range stats {
cntMap[s.SubjUID] = s.Cnt
thumbMap[s.SubjUID] = s.Thumb
}
filtered := make([]PpSubjectLite, 0, len(stats))
for _, s := range subjects {
cnt, ok := cntMap[s.UID]
if !ok || cnt == 0 {
continue
}
s.PhotoCount = int(cnt)
if th := thumbMap[s.UID]; th != "" {
s.Thumb = th
}
filtered = append(filtered, s)
}
c.JSON(http.StatusOK, filtered)
}
}
// unnamedFaceCluster is one face cluster PhotoPrism has detected but
// nobody has named yet. Naming happens the same way PhotoPrism's own
// People→New tab does it: PUT /api/v1/markers/<markerUid> with
// {Name, SubjSrc:"manual"} — PhotoPrism then creates the Subject and
// propagates it across the cluster (verified against
// internal/api/markers.go + frontend/src/model/face.js).
type unnamedFaceCluster struct {
FaceID string `json:"faceId" gorm:"column:face_id"`
// Photos under the caller's scope carrying this face.
Count int64 `json:"count" gorm:"column:cnt"`
// Marker crop hash — renders via /api/v1/t/<thumb>/<token>/tile_320.
Thumb string `json:"thumb" gorm:"column:thumb"`
// Representative marker (largest face in scope) — the PUT target
// when the user names this cluster.
MarkerUID string `json:"markerUid" gorm:"column:marker_uid"`
}
// handleUnnamedFaces lists face clusters awaiting a name, scoped to the
// caller's BasePath (admins with no BasePath see the whole library).
// Ordered by in-scope photo count so the most prominent people surface
// first.
//
// Route: GET /api/sidecar/faces/unnamed (behind requireSession)
func handleUnnamedFaces(ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if ppDb == nil {
c.JSON(http.StatusOK, gin.H{"clusters": []unnamedFaceCluster{}})
return
}
basePath := ctxBasePath(c)
where := ""
args := []any{}
if basePath != "" {
where = "AND (p.photo_path = ? OR p.photo_path LIKE ?)"
args = []any{basePath, basePath + "/%"}
}
var clusters []unnamedFaceCluster
if err := ppDb.Raw(`
SELECT m.face_id AS face_id,
COUNT(DISTINCT p.id) AS cnt,
SUBSTRING_INDEX(GROUP_CONCAT(m.thumb ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS thumb,
SUBSTRING_INDEX(GROUP_CONCAT(m.marker_uid ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS marker_uid
FROM markers m
JOIN files f ON f.file_uid = m.file_uid AND f.file_missing = 0
JOIN photos p ON p.photo_uid = f.photo_uid AND p.deleted_at IS NULL
JOIN faces fc ON fc.id = m.face_id AND fc.face_hidden = 0
WHERE m.marker_type = 'face'
AND m.marker_invalid = 0
AND (m.subj_uid IS NULL OR m.subj_uid = '')
AND m.face_id <> ''
`+where+`
GROUP BY m.face_id
ORDER BY cnt DESC
LIMIT 60
`, args...).Scan(&clusters).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "face cluster query failed"})
return
}
if clusters == nil {
clusters = []unnamedFaceCluster{}
}
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
}
}

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

395
sidecar/handlers_ppproxy.go Normal file
View File

@@ -0,0 +1,395 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
gopath "path"
"regexp"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Scoped PhotoPrism-compatible API proxy.
//
// PhotoPrism CE does not enforce auth_users.base_path on API reads — any
// authenticated user can search the whole library (`q=path:"other/*"`),
// verified empirically against this deployment. The web client compensates
// by post-filtering inside /api/sidecar/*, but third-party PhotoPrism apps
// (Gallery for PhotoPrism, Photo Uploader, …) speak to /api/v1 directly.
//
// This proxy is the public face for those apps. It forwards /api/v1/* to
// PhotoPrism with these rules for sessions that carry a BasePath:
//
// - search endpoints (photos, geo) get their `path` filter rewritten so
// results stay inside the caller's BasePath subtree;
// - per-photo reads and mutations the web client needs (metadata PUT,
// approve, like, stack file ops) are ownership-checked per UID;
// - batch archive/restore/delete validates every UID in the body against
// the PhotoPrism DB before forwarding;
// - hash-addressed media (t/, dl/, videos/) and session/config pass
// through — media URLs embed per-instance preview/download tokens and
// unguessable content hashes, the same protection PhotoPrism's own
// share links rely on;
// - album + label + subject reads and album/subject mutations pass
// through: PhotoPrism CE has no per-user albums or faces, so these are
// shared across users by design; the photos inside stay path-scoped;
// - everything else (settings, users, index, import, uploads) answers 403.
//
// Admin-role sessions (and any session without a BasePath) pass through
// fully — the web client's admin dialogs (settings, users, indexing) need
// the raw API.
// ppQPathTerm matches a `path:` filter inside PhotoPrism's q-DSL — either
// quoted (path:"a b/*") or bare (path:a/*).
var ppQPathTerm = regexp.MustCompile(`(?i)\bpath:("[^"]*"|\S+)`)
// pathValueAllowed reports whether one path-filter value stays inside base.
// PhotoPrism ORs `|`-separated alternatives inside a single value, so every
// alternative must pass. A bare `base*` (no slash) is rejected because the
// wildcard would also match sibling folders like `base2/…`.
func pathValueAllowed(val, base string) bool {
val = strings.Trim(val, `"`)
for _, alt := range strings.Split(val, "|") {
v := strings.Trim(strings.TrimSpace(alt), "/")
if v == base {
continue
}
if strings.HasPrefix(v, base+"/") {
continue
}
return false
}
return true
}
// scopeQ rewrites a q-DSL string so its path filter cannot leave base.
// User-supplied path terms that already stay inside base are kept (the web
// client and gallery apps use them for folder drills); any term that
// escapes — or the absence of one — collapses to `path:"base/*"`.
func scopeQ(q, base string) string {
terms := ppQPathTerm.FindAllStringSubmatch(q, -1)
if len(terms) > 0 {
ok := true
for _, m := range terms {
if !pathValueAllowed(m[1], base) {
ok = false
break
}
}
if ok {
return q
}
q = strings.TrimSpace(ppQPathTerm.ReplaceAllString(q, ""))
}
scope := ` path:"` + strings.ReplaceAll(base, `"`, "") + `/*"`
return strings.TrimSpace(q + scope)
}
// scopeSearchValues enforces the BasePath on a search request's query
// string. The q-DSL `path:` term overrides the `path` form parameter in
// PhotoPrism's parser (verified empirically), so the guarantee lives in q;
// the standalone param is validated too so it can't disagree.
func scopeSearchValues(v url.Values, base string) url.Values {
v.Set("q", scopeQ(v.Get("q"), base))
if p := v.Get("path"); p != "" && !pathValueAllowed(p, base) {
v.Del("path")
}
return v
}
// ppProxyPhoto is the projection needed for per-UID ownership checks.
type ppProxyPhoto struct {
Path string `json:"Path"`
Files []ppFile `json:"Files"`
}
// photoWithinBase fetches one photo with the caller's own token and checks
// that it lives inside base. Fails closed on any error.
func photoWithinBase(ctx context.Context, pp *ppClient, token, uid, base string) bool {
resp, err := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil)
if err != nil || !resp.OK {
return false
}
var p ppProxyPhoto
if err := json.Unmarshal(resp.Body, &p); err != nil {
return false
}
if p.Path != "" {
return p.Path == base || strings.HasPrefix(p.Path, base+"/")
}
for _, f := range p.Files {
if f.Root == "/" && strings.HasPrefix(f.Name, base+"/") {
return true
}
}
return false
}
// cachedSession is a short-lived token→user cache so a burst of gallery
// requests doesn't double every call with a /session probe. 60s matches
// the BasePath reconciler cadence; a revoked token lives at most that long.
type cachedSession struct {
user *ppSessionUser
expiry time.Time
}
type sessionCache struct {
mu sync.Mutex
m map[string]cachedSession
}
func (sc *sessionCache) resolve(ctx context.Context, pp *ppClient, token string) *ppSessionUser {
if token == "" {
return nil
}
now := time.Now()
sc.mu.Lock()
if e, ok := sc.m[token]; ok && now.Before(e.expiry) {
sc.mu.Unlock()
return e.user
}
sc.mu.Unlock()
user := pp.resolveSession(ctx, token)
if user == nil {
return nil
}
sc.mu.Lock()
if len(sc.m) > 1024 { // hard cap; sessions are few, tokens churn rarely
sc.m = map[string]cachedSession{}
}
sc.m[token] = cachedSession{user: user, expiry: now.Add(60 * time.Second)}
sc.mu.Unlock()
return user
}
// proxyToken pulls the session token from any header form PhotoPrism
// clients use: X-Auth-Token (canonical), Authorization: Bearer, or the
// legacy X-Session-ID.
func proxyToken(r *http.Request) string {
if t := r.Header.Get("X-Auth-Token"); t != "" {
return t
}
if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") {
return strings.TrimPrefix(a, "Bearer ")
}
return r.Header.Get("X-Session-ID")
}
// markerWithinBase reports whether a marker's underlying photo lives under
// base. Fails closed: no DB handle or unknown marker → false.
func markerWithinBase(ppDb *gorm.DB, markerUID, base string) bool {
if ppDb == nil || markerUID == "" {
return false
}
var n int64
err := ppDb.Table("markers m").
Joins("JOIN files f ON f.file_uid = m.file_uid").
Joins("JOIN photos p ON p.photo_uid = f.photo_uid").
Where("m.marker_uid = ?", markerUID).
Where("p.deleted_at IS NULL").
Where("p.photo_path = ? OR p.photo_path LIKE ?", base, base+"/%").
Count(&n).Error
if err != nil {
slog.Warn("pp-proxy: marker ownership query failed", "err", err)
return false
}
return n > 0
}
// batchUIDsWithinBase validates that every photo UID in a batch body lives
// under base, using one SQL query against PhotoPrism's photos table. Fails
// closed: no DB handle, unknown UIDs, or any path outside base → false.
func batchUIDsWithinBase(ppDb *gorm.DB, uids []string, base string) bool {
if ppDb == nil || len(uids) == 0 {
return false
}
var n int64
err := ppDb.Table("photos").
Where("photo_uid IN ?", uids).
Where("photo_path = ? OR photo_path LIKE ?", base, base+"/%").
Count(&n).Error
if err != nil {
slog.Warn("pp-proxy: batch ownership query failed", "err", err)
return false
}
return n == int64(len(uids))
}
// readBatchBody consumes the request body, extracts the `photos` UID list,
// and reinstates the body so the proxy can still forward it.
func readBatchBody(r *http.Request) ([]string, bool) {
buf, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
r.ContentLength = int64(len(buf))
if err != nil {
return nil, false
}
var body struct {
Photos []string `json:"photos"`
}
if err := json.Unmarshal(buf, &body); err != nil {
return nil, false
}
return body.Photos, true
}
// handlePPProxy returns the gin handler mounted at /api/v1/*rest.
func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
target, err := url.Parse(cfg.PhotoprismBaseURL)
if err != nil {
slog.Error("pp-proxy: bad PHOTOPRISM_BASE_URL", "err", err)
return func(c *gin.Context) { c.AbortWithStatus(http.StatusBadGateway) }
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn("pp-proxy: upstream error", "path", r.URL.Path, "err", err)
w.WriteHeader(http.StatusBadGateway)
}
// Ownership checks reuse the normal client; a dedicated instance would
// gain nothing.
pp := newPPClient(cfg.PhotoprismBaseURL)
cache := &sessionCache{m: map[string]cachedSession{}}
forbid := func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden,
gin.H{"error": "not available through this proxy"})
}
return func(c *gin.Context) {
// The router keeps raw escapes (UseRawPath). Unescape and clean
// before classifying, then forward exactly the cleaned path — so
// `t%2F..%2Fsettings` can't be classified as media here yet reach
// /settings after PhotoPrism's own router cleans it.
unesc, err := url.PathUnescape(c.Param("rest"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "bad path"})
return
}
rest := strings.TrimPrefix(gopath.Clean("/"+unesc), "/")
c.Request.URL.Path = "/api/v1/" + rest
c.Request.URL.RawPath = ""
method := c.Request.Method
// Unauthenticated / token-in-URL surface: login+logout, OIDC
// login+callback (PhotoPrism's actual routes are /api/v1/oidc/login
// and /api/v1/oidc/redirect — "oauth/" was never a real PhotoPrism
// path and left the Authentik callback with no valid token yet
// falling through to the authenticated branch below, producing a
// 401 "invalid session" before the session was even established),
// client config, hash-addressed media, websocket.
passUnscoped := rest == "session" || strings.HasPrefix(rest, "session/") ||
strings.HasPrefix(rest, "oidc/") ||
rest == "config" || rest == "ws" ||
strings.HasPrefix(rest, "t/") ||
strings.HasPrefix(rest, "dl/") ||
strings.HasPrefix(rest, "videos/") ||
strings.HasPrefix(rest, "svg/")
if passUnscoped {
proxy.ServeHTTP(c.Writer, c.Request)
return
}
user := cache.resolve(c.Request.Context(), pp, proxyToken(c.Request))
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return
}
base := strings.Trim(user.BasePath, "/")
if base == "" || user.Role == "admin" {
// Admins keep the raw API — the web client's settings, users,
// and indexing dialogs need it. (On this deployment the admin
// account carries a BasePath purely to default its web view.)
proxy.ServeHTTP(c.Writer, c.Request)
return
}
isGet := method == http.MethodGet || method == http.MethodHead
switch {
// Search endpoints: enforce the path scope inside the query.
case isGet && (rest == "photos" || rest == "photos/view" || rest == "geo"):
q := c.Request.URL.Query()
c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode()
proxy.ServeHTTP(c.Writer, c.Request)
// Batch mutations: every UID in the body must be the caller's.
case method == http.MethodPost && (rest == "batch/photos/archive" ||
rest == "batch/photos/restore" || rest == "batch/photos/delete" ||
rest == "batch/photos/approve" || rest == "batch/photos/private"):
uids, ok := readBatchBody(c.Request)
if !ok || !batchUIDsWithinBase(ppDb, uids, base) {
forbid(c)
return
}
proxy.ServeHTTP(c.Writer, c.Request)
// Per-photo operations: ownership-checked per UID. Covers reads,
// metadata PUT, approve, like/unlike, download, and the stack file
// ops (set primary / unstack / delete file) the review UI uses.
case strings.HasPrefix(rest, "photos/"):
parts := strings.Split(rest, "/")
uid := parts[1]
var allowed bool
switch len(parts) {
case 2:
allowed = isGet || method == http.MethodPut
case 3:
allowed = (isGet && parts[2] == "dl") ||
(method == http.MethodPost && parts[2] == "approve") ||
(parts[2] == "like" && (method == http.MethodPost || method == http.MethodDelete))
case 4:
allowed = method == http.MethodDelete && parts[2] == "files"
case 5:
allowed = method == http.MethodPost && parts[2] == "files" &&
(parts[4] == "primary" || parts[4] == "unstack")
}
if !allowed {
forbid(c)
return
}
if !photoWithinBase(c.Request.Context(), pp, proxyToken(c.Request), uid, base) {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "photo not found"})
return
}
proxy.ServeHTTP(c.Writer, c.Request)
// Albums (heaps), labels, subjects, faces: shared across users by
// design in CE — reads and mutations pass through; the photos inside
// any of them stay path-scoped by the rules above. Note that naming
// a face (marker PUT below) creates/updates a shared Subject the
// same way album/label edits are shared.
case rest == "albums" || strings.HasPrefix(rest, "albums/") ||
rest == "labels" || strings.HasPrefix(rest, "labels/") ||
rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
rest == "faces" || strings.HasPrefix(rest, "faces/"):
proxy.ServeHTTP(c.Writer, c.Request)
// Marker mutations (face naming / clearing): ownership-checked —
// the marker's file must belong to a photo under the caller's
// BasePath. This is how the web client names people (PhotoPrism's
// own naming flow is PUT /markers/:uid {Name, SubjSrc:"manual"}).
case (method == http.MethodPut && strings.HasPrefix(rest, "markers/") && strings.Count(rest, "/") == 1) ||
(method == http.MethodDelete && strings.HasPrefix(rest, "markers/") && strings.HasSuffix(rest, "/subject")):
markerUID := strings.TrimSuffix(strings.TrimPrefix(rest, "markers/"), "/subject")
if !markerWithinBase(ppDb, markerUID, base) {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "marker not found"})
return
}
proxy.ServeHTTP(c.Writer, c.Request)
default:
slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest)
forbid(c)
}
}
}

View File

@@ -0,0 +1,222 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestScopeQ(t *testing.T) {
cases := []struct {
name string
q string
base string
want string
}{
{"empty q gains scope", "", "dtoro", `path:"dtoro/*"`},
{"plain search gains scope", "label:dog", "dtoro", `label:dog path:"dtoro/*"`},
{"inside path kept", `path:"dtoro/2024/*" label:dog`, "dtoro", `path:"dtoro/2024/*" label:dog`},
{"exact base kept", `path:"dtoro"`, "dtoro", `path:"dtoro"`},
{"outside path replaced", `path:"muli/*"`, "dtoro", `path:"dtoro/*"`},
{"bare term outside replaced", `path:muli/x label:dog`, "dtoro", `label:dog path:"dtoro/*"`},
{"pipe alternative escaping", `path:"dtoro/*|muli/*"`, "dtoro", `path:"dtoro/*"`},
{"pipe all inside kept", `path:"dtoro/a|dtoro/b/*"`, "dtoro", `path:"dtoro/a|dtoro/b/*"`},
{"sibling prefix rejected", `path:"dtoro2/*"`, "dtoro", `path:"dtoro/*"`},
{"bare wildcard on base rejected", `path:dtoro*`, "dtoro", `path:"dtoro/*"`},
{"mixed valid+invalid terms collapse", `path:"dtoro/a" path:"muli/b"`, "dtoro", `path:"dtoro/*"`},
{"case-insensitive filter name", `PATH:"muli/*"`, "dtoro", `path:"dtoro/*"`},
{"nested base", `path:"family/alice/x"`, "family/alice", `path:"family/alice/x"`},
{"nested base parent escape", `path:"family/*"`, "family/alice", `path:"family/alice/*"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := scopeQ(tc.q, tc.base); got != tc.want {
t.Errorf("scopeQ(%q, %q) = %q, want %q", tc.q, tc.base, got, tc.want)
}
})
}
}
func TestScopeSearchValues(t *testing.T) {
v := url.Values{}
v.Set("count", "60")
v.Set("path", "muli/*")
got := scopeSearchValues(v, "dtoro")
if got.Get("path") != "" {
t.Errorf("outside path param should be dropped, got %q", got.Get("path"))
}
if got.Get("q") != `path:"dtoro/*"` {
t.Errorf("q should carry the scope, got %q", got.Get("q"))
}
if got.Get("count") != "60" {
t.Errorf("unrelated params must survive, got count=%q", got.Get("count"))
}
v2 := url.Values{}
v2.Set("path", "dtoro/2024")
got2 := scopeSearchValues(v2, "dtoro")
if got2.Get("path") != "dtoro/2024" {
t.Errorf("inside path param should be kept, got %q", got2.Get("path"))
}
}
// fakePP stands in for PhotoPrism: answers /session per token, echoes
// every other request's method+path+query back as JSON.
func fakePP(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/session" && r.Method == http.MethodGet {
var user map[string]any
switch r.Header.Get("X-Auth-Token") {
case "tok-scoped":
user = map[string]any{"UID": "u1", "Name": "alice", "Role": "user", "BasePath": "alice"}
case "tok-admin":
user = map[string]any{"UID": "u0", "Name": "root", "Role": "admin", "BasePath": "alice"}
default:
w.WriteHeader(http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(map[string]any{"user": user})
return
}
if strings.HasPrefix(r.URL.Path, "/api/v1/photos/") && r.Method == http.MethodGet &&
strings.Count(r.URL.Path, "/") == 4 {
uid := strings.TrimPrefix(r.URL.Path, "/api/v1/photos/")
path := "alice/2024"
if strings.HasPrefix(uid, "foreign") {
path = "bob/2024"
}
json.NewEncoder(w).Encode(map[string]any{"Path": path})
return
}
json.NewEncoder(w).Encode(map[string]any{
"echo": r.Method + " " + r.URL.Path,
"query": r.URL.RawQuery,
"handled": true,
})
}))
}
func proxyRig(t *testing.T) (*httptest.Server, func()) {
t.Helper()
up := fakePP(t)
cfg := &Config{PhotoprismBaseURL: up.URL}
gin.SetMode(gin.TestMode)
r := gin.New()
r.UseRawPath = true
r.UnescapePathValues = false
r.Any("/api/v1/*rest", handlePPProxy(cfg, nil))
front := httptest.NewServer(r)
return front, func() { front.Close(); up.Close() }
}
func proxyReq(t *testing.T, front, method, path, token string) (int, string) {
t.Helper()
req, _ := http.NewRequest(method, front+path, nil)
if token != "" {
req.Header.Set("X-Auth-Token", token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
return resp.StatusCode, sb.String()
}
func TestProxyRouting(t *testing.T) {
front, done := proxyRig(t)
defer done()
t.Run("media passes unauthenticated", func(t *testing.T) {
code, body := proxyReq(t, front.URL, "GET", "/api/v1/t/abc/tok/tile_224", "")
if code != 200 || !strings.Contains(body, "/api/v1/t/abc/tok/tile_224") {
t.Errorf("thumb should pass through, got %d %s", code, body)
}
})
t.Run("scoped search gains path scope", func(t *testing.T) {
code, body := proxyReq(t, front.URL, "GET", "/api/v1/photos?count=3&q="+url.QueryEscape(`path:"bob/*"`), "tok-scoped")
if code != 200 {
t.Fatalf("got %d", code)
}
var out struct{ Query string }
json.Unmarshal([]byte(body), &out)
q, _ := url.ParseQuery(out.Query)
if q.Get("q") != `path:"alice/*"` {
t.Errorf("escaping q must collapse to own scope, got %q", q.Get("q"))
}
})
t.Run("scoped settings blocked", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-scoped")
if code != http.StatusForbidden {
t.Errorf("settings should 403 for scoped user, got %d", code)
}
})
t.Run("admin settings passes", func(t *testing.T) {
code, body := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-admin")
if code != 200 || !strings.Contains(body, "/api/v1/settings") {
t.Errorf("admin should pass through, got %d %s", code, body)
}
})
t.Run("no token unauthorized", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos", "")
if code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", code)
}
})
t.Run("own photo readable, foreign 404", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos/mine123", "tok-scoped")
if code != 200 {
t.Errorf("own photo should pass, got %d", code)
}
code, _ = proxyReq(t, front.URL, "GET", "/api/v1/photos/foreign9", "tok-scoped")
if code != http.StatusNotFound {
t.Errorf("foreign photo should 404, got %d", code)
}
})
t.Run("encoded traversal cannot reach settings as media", func(t *testing.T) {
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/t%2F..%2Fsettings", "tok-scoped")
if code != http.StatusForbidden {
t.Errorf("traversal should classify as settings and 403, got %d", code)
}
})
t.Run("batch without db fails closed", func(t *testing.T) {
req, _ := http.NewRequest("POST", front.URL+"/api/v1/batch/photos/archive",
strings.NewReader(`{"photos":["p1"]}`))
req.Header.Set("X-Auth-Token", "tok-scoped")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("batch with nil ppDb must 403, got %d", resp.StatusCode)
}
})
}
func TestPathValueAllowed(t *testing.T) {
if pathValueAllowed(`"muli/*"`, "dtoro") {
t.Error("outside value must be rejected")
}
if !pathValueAllowed(`"dtoro/Photos/2024"`, "dtoro") {
t.Error("inside value must be allowed")
}
if pathValueAllowed("dtoro/a|muli/b", "dtoro") {
t.Error("any escaping pipe alternative must reject the whole value")
}
}

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

@@ -97,6 +97,9 @@ func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"}) c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
return return
} }
if !requireUserScope(c, cfg, oldAbs, false) {
return
}
st, err := os.Stat(oldAbs) st, err := os.Stat(oldAbs)
if err != nil || !st.Mode().IsRegular() { if err != nil || !st.Mode().IsRegular() {
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"}) c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})

View File

@@ -20,6 +20,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func main() { func main() {
@@ -46,6 +47,18 @@ func main() {
// BasePath wired without an admin restart. // BasePath wired without an admin restart.
startUserBasepathReconciler(cfg) 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) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded // Keep `%2F` literal in path params so callers can pass URL-encoded
@@ -68,6 +81,9 @@ func main() {
// under one group keeps the middleware wiring obvious. // under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp)) auth := r.Group("/api/sidecar", requireSession(pp))
{ {
auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.GET("/photos/marks", handleMarksAll(db)) auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db)) auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db)) auth.PUT("/photos/:uid/marks", handleMarkPut(db))
@@ -78,14 +94,45 @@ func main() {
auth.POST("/folders", handleFolderCreate(cfg, pp)) auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp)) auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, 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.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
auth.POST("/duplicates/restore", handleDupRestore(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))
auth.GET("/subjects", handleSubjects(pp, ppDb))
auth.GET("/faces/unnamed", handleUnnamedFaces(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))
} }
// PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
// both the web client and third-party PhotoPrism apps (Caddy routes
// /api/v1 here instead of straight to PhotoPrism, which does not
// enforce base_path in CE). See handlers_ppproxy.go for the rules.
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
addr := cfg.ListenAddr + ":" + itoa(cfg.Port) addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
@@ -117,4 +164,3 @@ func main() {
} }
<-idleClosed <-idleClosed
} }

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@@ -85,6 +86,45 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
}, nil }, nil
} }
// ppSessionUser is the subset of PhotoPrism's session response we need.
type ppSessionUser struct {
UserUID string `json:"UID"`
UserName string `json:"Name"`
Role string `json:"Role"`
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: // validateSession is the cheapest probe that the supplied token is live:
// list one photo. 401 → bad/expired token. We never read the payload. // list one photo. 401 → bad/expired token. We never read the payload.
func (c *ppClient) validateSession(ctx context.Context, token string) bool { func (c *ppClient) validateSession(ctx context.Context, token string) bool {

View File

@@ -86,12 +86,15 @@ func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]stri
// ACL kicks in even if the directory is created later. // ACL kicks in even if the directory is created later.
} }
// upload_path rides along with base_path so anything a client app
// uploads (WebDAV sync apps, PhotoPrism's own UI) lands inside the
// user's library subtree instead of the shared originals root.
res := db.Exec(`UPDATE auth_users res := db.Exec(`UPDATE auth_users
SET base_path = ? SET base_path = ?, upload_path = ?
WHERE user_name = ? WHERE user_name = ?
AND COALESCE(base_path, '') <> ? AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
AND deleted_at IS NULL`, AND deleted_at IS NULL`,
path, username, path) path, path, username, path, path)
if res.Error != nil { if res.Error != nil {
slog.Error("user-basepath: update failed", "user", username, "err", res.Error) slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
continue continue

247
web/package-lock.json generated
View File

@@ -14,7 +14,6 @@
"bits-ui": "^2.18.1", "bits-ui": "^2.18.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"maplibre-gl": "^5.24.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-sonner": "^1.1.1", "svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
@@ -148,110 +147,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "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==", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "25.8.0", "version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
@@ -1014,15 +903,6 @@
"undici-types": ">=7.24.0 <7.24.7" "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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1248,12 +1128,6 @@
"node": ">= 0.4" "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": { "node_modules/enhanced-resolve": {
"version": "5.21.3", "version": "5.21.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
@@ -1451,12 +1325,6 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1553,18 +1421,6 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1879,40 +1735,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1952,15 +1774,6 @@
"node": ">= 0.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": { "node_modules/mode-watcher": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
@@ -2050,12 +1863,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -2086,18 +1893,6 @@
], ],
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2147,18 +1942,6 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -2168,12 +1951,6 @@
"node": ">=10" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2188,15 +1965,6 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/rolldown": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2309,15 +2077,6 @@
"inline-style-parser": "0.2.7" "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": { "node_modules/svelte": {
"version": "5.55.7", "version": "5.55.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
@@ -2489,12 +2248,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",

View File

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

View File

@@ -4,6 +4,11 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" /> <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="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" /> <link rel="apple-touch-icon" href="/favicon.png" />
%sveltekit.head% %sveltekit.head%

View File

@@ -7,11 +7,18 @@ import {
batchArchive, batchArchive,
batchDelete, batchDelete,
batchRestore, batchRestore,
bulkSetMarks,
removeFromHeap, removeFromHeap,
type PhotoMark,
type PhotoMarksMap,
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto, toggleFavorite } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import { import {
clearBulkToFirst, clearBulkToFirst,
clearSelection, clearSelection,
@@ -24,8 +31,22 @@ import {
toggle toggle
} from '$lib/stores/selection.svelte'; } from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte'; import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; import {
import type { PpPhoto } from '$lib/types/photoprism'; startBulk,
doneBulk,
removedBulk,
failBulk,
setDetail,
markRemoved
} from '$lib/stores/bulkAction.svelte';
import {
closeShortcuts,
openPreview,
toggleLeftSidebar,
toggleRightSidebar,
toggleShortcuts,
view
} from '$lib/stores/view.svelte';
/** /**
* Optional parameters the host passes via `use:gridKeyNav={...}`. * Optional parameters the host passes via `use:gridKeyNav={...}`.
@@ -57,8 +78,8 @@ export interface GridKeyNavParams {
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles * heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes, * left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
* ⌘A selects all visible. * ⌘A selects all visible.
* Rating + color labels are mouse-driven via the metadata sidebar — no * 05 rating, 69 Lightroom color labels, / focuses search,
* keyboard shortcuts. * ? opens the shortcut reference overlay.
* *
* Archive / restore target a synthesized "cull target list" — in priority: * Archive / restore target a synthesized "cull target list" — in priority:
* 1. multi-selection set * 1. multi-selection set
@@ -160,34 +181,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return []; return [];
} }
/** Look up a photo's current cached state without forcing a refetch. const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
* Walks every `['photos', …]` cache entry first, then the per-photo
* cache. Lets `x` decide "archive vs restore" based on the actual current
* state instead of always sending Archived=true.
*
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
* scroll. Walk both — assuming a flat array on the timeline cache used
* to throw `list.find is not a function` and abort the F/X handlers. */
function cachedPhoto(uid: string): PpPhoto | undefined {
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid);
if (hit) return hit;
}
}
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
}
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') { async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
const ids = cullTargets(); const ids = cullTargets();
@@ -207,34 +201,43 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
target = !(first?.Archived ?? false); target = !(first?.Archived ?? false);
} }
// PhotoPrism's photo PUT silently drops the Archived field — the const opLabel = target ? 'Archiving' : 'Restoring';
// only working path is /api/v1/batch/photos/{archive,restore}. The const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
// previous patchTargets call PUT'd `{Archived: true}` and got a 200 const tid = toast.loading(`${opLabel} ${ids.length}`);
// back, so the toast fired but nothing moved. startBulk(`${opLabel}`, ids);
try { try {
if (target) await batchArchive(ids); if (target) await batchArchive(ids);
else await batchRestore(ids); else await batchRestore(ids);
} catch (err) { } 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; return;
} }
// Move focus forward before the photos query refetches, so the if (target) {
// user can keep X-ing through the timeline without their cursor // Destructive removal: flash a red cross, then pull the tiles out of
// snapping back to photo[0]. Walks past every uid we just // the grid immediately (markRemoved) rather than waiting on the slow
// archived/restored — relevant when the cull targets came from a // server-reconcile refetch. The grid reconciles `removedIds` against
// multi-selection rather than the single focused tile. // the cache and drops each id once the archived-filtered page has
focusAfter(ids); // actually replaced it (see +page.svelte), so we don't clear here —
// Drop the now-stale selection set. The archived UIDs are about // clearing on this action's own settle raced other in-flight archives
// to leave the timeline on refetch, but the SvelteSet membership // and flashed photos back in.
// keeps the selection ring on them until then — confusing for removedBulk(doneLabel, ids);
// the user and a footgun if they Ctrl-click to add more and end focusAfter(ids);
// up re-archiving the same photos. The BulkActionBar button path clearSelection();
// clears for the same reason; mirror it here. await delay(500);
clearSelection(); markRemoved(ids);
invalidatePhotos(ids); invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; void queryClient.invalidateQueries({ queryKey: ['photos'] });
toast.success(label); void queryClient.invalidateQueries({ queryKey: ['marks'] });
pushUndo(label, async () => { } 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); if (target) await batchRestore(ids);
else await batchArchive(ids); else await batchArchive(ids);
invalidatePhotos(ids); invalidatePhotos(ids);
@@ -259,16 +262,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
? 'Permanently delete this photo? This cannot be undone.' ? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`; : `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return; if (!confirm(msg)) return;
const tid = toast.loading(`Deleting ${ids.length}`);
startBulk('Deleting…', ids);
try { try {
await batchDelete(ids); await batchDelete(ids);
} catch (err) { } 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; return;
} }
// Destructive removal — same red-cross flash then immediate hide as archive.
removedBulk(`Deleted ${ids.length}`, ids);
focusAfter(ids); focusAfter(ids);
clearSelection(); clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(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 /** Approve cull targets — clears them out of the review pile by
@@ -284,21 +298,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
}); });
return; return;
} }
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id)); const tid = toast.loading(`Keeping ${ids.length}`);
// Approve moves photos out of the review pile, so the same startBulk('Keeping…', ids);
// stale-selection trap as archive/delete applies — advance focus const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
// past the approved set and drop the now-irrelevant selection onProgress: (_done, _total, completedId) => {
// before invalidate refetches the (smaller) view. 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); focusAfter(ids);
clearSelection(); clearSelection();
invalidatePhotos(ids); 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) ──────────────────────────────────────────── // ── S chord (add-to-heap) ────────────────────────────────────────────
@@ -324,25 +344,28 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
}); });
return; return;
} }
const tid = toast.loading(`Adding ${ids.length}${heap.Title}`);
startBulk(`Adding to ${heap.Title}`, ids);
try { try {
const { added } = await addToHeap(heap.UID, ids); const { added } = await addToHeap(heap.UID, ids);
void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] }); 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) { if (added.length === 0) {
failBulk(ids);
toast.error(`Nothing added to ${heap.Title}`, { toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).` id: tid,
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
}); });
return; return;
} }
doneBulk(`Added ${added.length}${heap.Title}`, ids);
if (added.length < ids.length) { if (added.length < ids.length) {
toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, { toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, {
id: tid,
description: 'The rest were already in this heap.' description: 'The rest were already in this heap.'
}); });
} else { } 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 () => { pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added); await removeFromHeap(heap.UID, added);
@@ -350,7 +373,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
void queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] });
}); });
} catch (err) { } 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 });
} }
} }
@@ -363,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
await addCullTargetsToHeap(heaps[idx - 1]); await addCullTargetsToHeap(heaps[idx - 1]);
} }
// ── Rating / color-label keys (Lightroom layout) ─────────────────────
// Bare 05 set the rating (0 clears; re-keying the current value also
// clears, matching the sidebar's click-to-toggle). 69 toggle the four
// Lightroom color labels. Multi-selection stamps the whole set.
const COLOR_KEYS: Record<string, string> = { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' };
async function markCullTargets(patch: PhotoMark, label: string) {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to mark', {
description: 'Click a photo or select some first'
});
return;
}
// Optimistic cache patch — the tile badges and facet panels read
// ['marks'], so stamping it up front makes the keystroke feel instant.
const prevMap = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
const next: PhotoMarksMap = { ...prevMap };
for (const id of ids) {
const merged: PhotoMark = { ...next[id], ...patch };
if (!merged.rating) delete merged.rating;
if (!merged.color) delete merged.color;
next[id] = merged;
}
queryClient.setQueryData(['marks'], next);
try {
await bulkSetMarks(ids, patch);
void queryClient.invalidateQueries({ queryKey: ['marks'] });
toast.success(ids.length === 1 ? label : `${label} · ${ids.length} photos`);
} catch (err) {
queryClient.setQueryData(['marks'], prevMap);
toast.error(err instanceof Error ? err.message : 'Mark failed');
}
}
function ratingOfFirstTarget(): number {
const ids = cullTargets();
if (ids.length === 0) return 0;
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
return marks[ids[0]]?.rating ?? 0;
}
function colorOfFirstTarget(): string {
const ids = cullTargets();
if (ids.length === 0) return '';
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
return marks[ids[0]]?.color ?? '';
}
async function addCullTargetsToActiveHeap() { async function addCullTargetsToActiveHeap() {
if (filters.section !== 'heap' || !filters.heapUid) { if (filters.section !== 'heap' || !filters.heapUid) {
toast.message('Press S then 19 to pick a heap'); toast.message('Press S then 19 to pick a heap');
@@ -382,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Shortcuts overlay: Esc or ? closes it; every other key is inert
// while it's up so the reference card can't trigger the actions it
// documents.
if (view.shortcutsOpen) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault();
closeShortcuts();
}
return;
}
// Modal owns arrow / Escape / Space while it's open — it handles // Modal owns arrow / Escape / Space while it's open — it handles
// its own linear nav, close-on-Esc, and close-on-Space. Action // its own linear nav, close-on-Esc, and close-on-Space. Action
// keys (X/S/U/A/Z) still pass through because they target the // keys (X/S/U/A/Z) still pass through because they target the
@@ -417,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey; const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey; const shift = e.shiftKey;
// Bare digits: rating (05, re-key toggles off) and Lightroom color
// labels (69). Runs after the S-chord so "s 3" still files to heap 3.
if (!meta && !shift && /^[0-9]$/.test(e.key)) {
e.preventDefault();
const n = parseInt(e.key, 10);
if (n <= 5) {
const value = n === 0 || ratingOfFirstTarget() === n ? 0 : n;
void markCullTargets({ rating: value }, value ? `Rated ${value}` : 'Rating cleared');
} else {
const color = COLOR_KEYS[e.key];
const value = colorOfFirstTarget() === color ? '' : color;
void markCullTargets({ color: value }, value ? `Labeled ${value}` : 'Color cleared');
}
return;
}
// Space on a focused tile opens the full-screen preview modal. // Space on a focused tile opens the full-screen preview modal.
// Matches the dblclick gesture so the user has both keyboard and // Matches the dblclick gesture so the user has both keyboard and
// mouse paths to the same surface. `e.code === 'Space'` covers // mouse paths to the same surface. `e.code === 'Space'` covers
@@ -463,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
clearSelection(); clearSelection();
setFocused(null); setFocused(null);
return; return;
case '/':
// Jump to the search box (any page that renders one tags it
// with data-search-input).
if (meta) return;
e.preventDefault();
document.querySelector<HTMLInputElement>('[data-search-input]')?.focus();
return;
case '?':
e.preventDefault();
toggleShortcuts();
return;
case 'Tab': case 'Tab':
// Tab in the grid context = mule-image's left-sidebar toggle. // Tab in the grid context = mule-image's left-sidebar toggle.
// Browsers reserve Tab for focus traversal — preventDefault // Browsers reserve Tab for focus traversal — preventDefault
@@ -498,6 +609,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (meta) { if (meta) {
e.preventDefault(); e.preventDefault();
for (const id of selection.order) selection.ids.add(id); 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; return;
case 'x': case 'x':
@@ -519,6 +653,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault(); e.preventDefault();
void toggleArchive('restore'); void toggleArchive('restore');
return; return;
case 'f':
case 'F':
if (meta || shift) return;
e.preventDefault();
void toggleFavorite(cullTargets());
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':
case 'S': case 'S':
if (meta || shift) return; if (meta || shift) return;

View File

@@ -0,0 +1,130 @@
/**
* Wheel-zoom + drag-pan for an image container. Extracted from
* PreviewPane so the compare lightbox can share the exact gesture
* behavior: wheel zooms around the cursor, double-click toggles
* 1 ↔ dblClickZoom, dragging pans while zoomed.
*
* The action owns the event listeners (wheel must be non-passive for
* preventDefault; Svelte marks template wheel handlers passive) and
* reports state through `onChange`. The consumer applies the transform
* to an inner wrapper:
*
* <div use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}>
* <div style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});">…
*
* `resetKey` resets to 1:1 whenever it changes (e.g. per photo). Keep
* it constant to preserve zoom/pan across content swaps — that's what
* makes pixel-compare flipping work in the lightbox.
*/
export interface ZoomPanState {
zoom: number;
tx: number;
ty: number;
panning: boolean;
}
export interface ZoomPanParams {
onChange: (state: ZoomPanState) => void;
/** Reset to 1:1 when this value changes. */
resetKey?: unknown;
maxZoom?: number;
dblClickZoom?: number;
}
export function zoomPan(node: HTMLElement, params: ZoomPanParams) {
let current = params;
const state: ZoomPanState = { zoom: 1, tx: 0, ty: 0, panning: false };
let lastX = 0;
let lastY = 0;
function emit() {
current.onChange({ ...state });
}
function reset() {
state.zoom = 1;
state.tx = 0;
state.ty = 0;
state.panning = false;
emit();
}
function applyZoom(next: number, clientX: number, clientY: number) {
const max = current.maxZoom ?? 6;
const clamped = Math.min(max, Math.max(1, next));
if (clamped === state.zoom) return;
// Keep the point under the cursor fixed: translate offsets are in
// post-scale pixels around the container centre.
const rect = node.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const s = clamped / state.zoom;
state.tx = cx + (state.tx - cx) * s;
state.ty = cy + (state.ty - cy) * s;
state.zoom = clamped;
if (state.zoom === 1) {
state.tx = 0;
state.ty = 0;
}
emit();
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyZoom(state.zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
}
function onDblClick(e: MouseEvent) {
if (state.zoom > 1) {
reset();
} else {
applyZoom(current.dblClickZoom ?? 2.5, e.clientX, e.clientY);
}
}
function onPointerDown(e: PointerEvent) {
if (state.zoom === 1) return;
state.panning = true;
lastX = e.clientX;
lastY = e.clientY;
node.setPointerCapture(e.pointerId);
emit();
}
function onPointerMove(e: PointerEvent) {
if (!state.panning) return;
state.tx += e.clientX - lastX;
state.ty += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
emit();
}
function onPointerUp() {
if (!state.panning) return;
state.panning = false;
emit();
}
node.addEventListener('wheel', onWheel, { passive: false });
node.addEventListener('dblclick', onDblClick);
node.addEventListener('pointerdown', onPointerDown);
node.addEventListener('pointermove', onPointerMove);
node.addEventListener('pointerup', onPointerUp);
node.addEventListener('pointercancel', onPointerUp);
return {
update(next: ZoomPanParams) {
const keyChanged = next.resetKey !== current.resetKey;
current = next;
if (keyChanged) reset();
},
destroy() {
node.removeEventListener('wheel', onWheel);
node.removeEventListener('dblclick', onDblClick);
node.removeEventListener('pointerdown', onPointerDown);
node.removeEventListener('pointermove', onPointerMove);
node.removeEventListener('pointerup', onPointerUp);
node.removeEventListener('pointercancel', onPointerUp);
}
};
}

View File

@@ -0,0 +1,156 @@
<!--
Fullscreen pixel-compare overlay for a duplicate stack. Shows one
candidate at a time at fit_2048; ←/→ flip between candidates while
PRESERVING zoom & pan (the whole point — zoom into an eye or a hair,
then flip to see which file is sharper). Enter picks the shown file
as the keeper and closes; Esc closes without picking.
All candidate <img>s stay mounted (stacks are 25 files) with only
the active one visible, so flips are instant once loaded and the
shared transform wrapper keeps them aligned.
Keys are intercepted at window-capture level while open so the group
card / global shortcuts underneath don't also react.
-->
<script lang="ts">
import { thumbUrl } from '$lib/stores/session.svelte';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import type { PpFile } from '$lib/types/photoprism';
interface Props {
files: PpFile[];
/** UID of the candidate shown first. */
startUid: string;
onPick: (uid: string) => void;
onClose: () => void;
}
let { files, startUid, onPick, onClose }: Props = $props();
let index = $state(0);
$effect.pre(() => {
const i = files.findIndex((f) => f.UID === startUid);
index = i >= 0 ? i : 0;
});
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
const active = $derived(files[index]);
function flip(delta: number) {
index = (index + delta + files.length) % files.length;
}
function sizeLabel(bytes?: number): string {
if (!bytes) return '';
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
function onKeydown(e: KeyboardEvent) {
// Swallow everything except modifier combos so the card / global
// shortcuts underneath stay inert while the lightbox is up.
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.stopPropagation();
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
flip(-1);
return;
case 'ArrowRight':
case ' ':
e.preventDefault();
flip(1);
return;
case 'Enter':
e.preventDefault();
onPick(active.UID);
return;
case 'Escape':
e.preventDefault();
onClose();
return;
default: {
const n = Number.parseInt(e.key, 10);
if (n >= 1 && n <= files.length) {
e.preventDefault();
index = n - 1;
}
}
}
}
</script>
<svelte:window onkeydowncapture={onKeydown} />
<div
class="fixed inset-0 z-50 flex flex-col bg-black/90"
role="dialog"
aria-modal="true"
aria-label="Compare stack files"
>
<!-- Caption / controls bar -->
<div class="flex items-center justify-between gap-3 px-4 py-2 text-xs text-white/90">
<div class="min-w-0 truncate font-mono">{active?.Name ?? ''}</div>
<div class="flex shrink-0 items-center gap-3">
{#if active?.Width && active?.Height}
<span>{active.Width}×{active.Height}</span>
{/if}
{#if active?.Size}
<span>{sizeLabel(active.Size)}</span>
{/if}
<span class="text-white/60">{index + 1} / {files.length}</span>
{#if zp.zoom > 1}
<span class="text-white/60">{Math.round(zp.zoom * 100)}%</span>
{/if}
<button
type="button"
class="rounded border border-white/30 px-2 py-0.5 hover:bg-white/10"
onclick={() => onPick(active.UID)}
>
Keep this <kbd class="ml-1 rounded bg-white/10 px-1 text-[9px]">Enter</kbd>
</button>
<button
type="button"
class="rounded border border-white/30 px-2 py-0.5 hover:bg-white/10"
onclick={onClose}
aria-label="Close compare view"
>
<kbd class="ml-1 rounded bg-white/10 px-1 text-[9px]">Esc</kbd>
</button>
</div>
</div>
<!-- Image stage — shared transform so flips stay pixel-aligned -->
<div
use:zoomPan={{ onChange: (s) => (zp = s) }}
class="relative min-h-0 flex-1 overflow-hidden {zp.zoom > 1
? zp.panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="flex h-full w-full items-center justify-center"
class:transition-transform={!zp.panning}
class:duration-150={!zp.panning}
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
>
{#each files as file, i (file.UID)}
<img
src={thumbUrl(file.Hash, 'fit_2048')}
alt={file.Name}
draggable="false"
decoding="async"
class="absolute max-h-full max-w-full select-none object-contain {i === index
? ''
: 'invisible'}"
/>
{/each}
</div>
</div>
<!-- Flip hint -->
<div class="px-4 py-2 text-center text-[11px] text-white/50">
←/→ flip candidates (zoom is preserved) · scroll to zoom · Enter keeps the shown file
</div>
</div>

View File

@@ -1,43 +1,33 @@
<!-- <!--
One cross-folder duplicate group rendered as a card. Lists every on-disk One cross-folder duplicate group. Every copy is byte-identical (same
copy of the same byte-identical file. The user picks one to keep; the sha1, same thumbnail) so the old N-identical-thumbnails grid told the
rest are archived to `.duplicates/<timestamp>/` via the sidecar. user nothing — the actual decision is entirely about *which path* to
keep. Redesigned as one thumbnail + a radio-style path list.
Differences from StackGroupCard (which operates on PhotoPrism Files in Resolution moves files (reversible, quarantine + undo) rather than
a single Photo stack): deletes — logic lives in services/duplicateActions.svelte.ts.
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
index time). They're files on disk only.
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
can render every copy from the same hash even though only one Photo
entry exists.
- Resolution moves files (reversible) rather than deletes (irreversible).
Same keyboard contract as StackGroupCard: arrows pick the keeper, Keyboard (↑/↓/j/k bubble to DuplicatesView's group navigation):
Enter commits. - ←/→ or 19 move the keeper pick.
- Enter archives every other copy.
--> -->
<script lang="ts"> <script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query'; import { resolveCrossFolder } from '$lib/services/duplicateActions.svelte';
import { toast } from 'svelte-sonner';
import {
archiveDuplicatePaths,
type CrossFolderDuplicateGroup
} from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte'; import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte'; import type { CrossFolderDuplicateGroup } from '$lib/services/photoprism';
import { Check, Clock } from 'lucide-svelte';
interface Props { interface Props {
group: CrossFolderDuplicateGroup; group: CrossFolderDuplicateGroup;
/** First-card auto-focus, same pattern as StackGroupCard. */ focused?: boolean;
autoFocus?: boolean; onFocusRequest?: () => void;
onResolved?: () => void;
} }
let { group, autoFocus = false }: Props = $props(); let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
const qc = useQueryClient();
let keep = $state(''); let keep = $state('');
let busy = $state(false); let busy = $state(false);
let sectionEl: HTMLElement | undefined = $state(); let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
// Seed `keep` from the indexed path when available; that's the safest // Seed `keep` from the indexed path when available; that's the safest
// default because losing it would leave PhotoPrism with no copy. Fall // default because losing it would leave PhotoPrism with no copy. Fall
@@ -48,38 +38,15 @@
keep = keep =
group.indexedPath && validPaths.has(group.indexedPath) group.indexedPath && validPaths.has(group.indexedPath)
? group.indexedPath ? group.indexedPath
: group.files[0]?.path ?? ''; : (group.files[0]?.path ?? '');
} }
}); });
$effect(() => { $effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true }); if (focused && sectionEl) {
}); sectionEl.focus({ preventScroll: true });
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Column-count tracking — identical pattern to StackGroupCard. }
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
}); });
function sizeLabel(bytes: number): string { function sizeLabel(bytes: number): string {
@@ -93,6 +60,38 @@
return segs.slice(0, -1).join('/'); return segs.slice(0, -1).join('/');
} }
/** Relative age label from mtime, e.g. "3mo older" — helps break ties
* when neither copy is the indexed one. */
function relAge(iso: string | undefined, newestMs: number): string {
if (!iso) return '';
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return '';
const diffDays = Math.round((newestMs - ms) / 86_400_000);
if (diffDays <= 0) return 'newest';
if (diffDays < 30) return `${diffDays}d older`;
if (diffDays < 365) return `${Math.round(diffDays / 30)}mo older`;
return `${Math.round(diffDays / 365)}y older`;
}
const newestMs = $derived(
Math.max(...group.files.map((f) => (f.modTime ? Date.parse(f.modTime) : 0)))
);
/** Highlight the differing folder segment(s) so the eye jumps straight
* to what's actually different between two long, mostly-shared paths. */
function highlightDiff(path: string): { prefix: string; diff: string; suffix: string } {
const common = group.files
.map((f) => f.path)
.reduce((acc, p) => {
let i = 0;
while (i < acc.length && i < p.length && acc[i] === p[i]) i++;
return acc.slice(0, i);
});
// Back up to the last '/' so we don't split mid-segment.
const cut = common.lastIndexOf('/') + 1;
return { prefix: path.slice(0, cut), diff: path.slice(cut), suffix: '' };
}
function moveKeep(delta: number) { function moveKeep(delta: number) {
const i = group.files.findIndex((f) => f.path === keep); const i = group.files.findIndex((f) => f.path === keep);
if (i < 0) return; if (i < 0) return;
@@ -102,71 +101,40 @@
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (busy) return; if (busy) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) { switch (e.key) {
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
moveKeep(-1); moveKeep(-1);
return; return;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
moveKeep(1); moveKeep(1);
return; return;
case 'ArrowUp':
e.preventDefault();
moveKeep(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveKeep(cols);
return;
case 'Enter': case 'Enter':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
void commit(); void commit();
return; return;
case 'Escape': default: {
(e.target as HTMLElement)?.blur(); const n = Number.parseInt(e.key, 10);
return; if (n >= 1 && n <= group.files.length) {
e.preventDefault();
e.stopPropagation();
keep = group.files[n - 1].path;
}
}
} }
} }
async function commit() { async function commit() {
if (busy || group.files.length < 2) return; if (busy || group.files.length < 2) return;
// Defensive guard: never archive the indexed copy. The user can
// pick a different "keeper" but the archive list is computed AFTER
// resolving that into "everything except the keeper". If they pick
// a non-indexed copy as keeper, the indexed one gets archived —
// PhotoPrism will lose its photo entry on the cleanup reindex.
// That's a legitimate user choice (they wanted to move the
// canonical copy), just call it out in the toast.
const losers = group.files.filter((f) => f.path !== keep);
if (losers.length === 0) return;
const losingIndexed =
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
busy = true; busy = true;
try { try {
const result = await archiveDuplicatePaths(losers.map((f) => f.path)); const ok = await resolveCrossFolder(group, keep);
if (result.errors.length > 0) { if (ok) onResolved?.();
toast.error(
`Archived ${result.moved.length}; ${result.errors.length} failed`,
{
description: result.errors[0].error
}
);
} else {
toast.success(
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
{
description: losingIndexed
? 'The previously-indexed copy was moved; PhotoPrism will drop it on the next index pass.'
: 'Files moved to .duplicates/ inside originals.'
}
);
}
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
} finally { } finally {
busy = false; busy = false;
} }
@@ -179,87 +147,101 @@
bind:this={sectionEl} bind:this={sectionEl}
tabindex="0" tabindex="0"
role="application" role="application"
aria-label={`Cross-folder duplicate · ${group.files.length} copies`} aria-label={`Duplicate group of ${group.files.length} copies — ←/→ pick which path to keep, Enter archives the rest`}
onkeydown={onKeydown} onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none onfocusin={() => onFocusRequest?.()}
focus-visible:ring-2 focus-visible:ring-primary/50" class="flex gap-3 rounded-md border bg-card/30 p-3 outline-none transition-colors
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
> >
<header class="flex items-center justify-between gap-3"> <!-- Single thumbnail — every copy is byte-identical, so N tiles of the
<div class="min-w-0"> same image told the user nothing. -->
<div class="w-28 shrink-0">
<div class="aspect-square w-full overflow-hidden rounded-md border border-border bg-secondary">
<img
src={thumbUrl(group.hash, 'tile_500')}
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
</div>
<div class="mt-1 truncate text-center text-[10px] font-mono text-muted-foreground/70">
sha1 {group.hash.slice(0, 10)}
</div>
</div>
<div class="min-w-0 flex-1 space-y-2">
<header class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-foreground"> <div class="text-sm font-medium text-foreground">
{group.files.length} copies · {sizeLabel(group.size)} each {group.files.length} copies · {sizeLabel(group.size)} each
</div> </div>
<div class="truncate text-[10px] font-mono text-muted-foreground">
sha1 {group.hash.slice(0, 16)}
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Move the unselected copies to .duplicates/ (reversible)"
>
Keep selected, archive rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</header>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.path)}
{@const isKeep = file.path === keep}
{@const isIndexed = file.path === group.indexedPath}
<button <button
type="button" type="button"
onclick={() => (keep = file.path)} class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class:scale-95={isKeep} disabled={busy || group.files.length < 2}
class:ring-2={isKeep} onclick={commit}
class:ring-blue-500={isKeep} title="Move the unselected copies to .duplicates/ (recoverable)"
class:ring-offset-2={isKeep}
class:ring-offset-background={isKeep}
class:transition-[transform,box-shadow]={isKeep}
class:duration-300={isKeep}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
> >
<div class="relative aspect-square w-full overflow-hidden"> Keep selected path
<img <kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
src={thumbUrl(group.hash, 'tile_500')} >Enter</kbd
alt={file.path}
loading="lazy"
class="h-full w-full object-cover"
/>
{#if isKeep}
<span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
Keep
</span>
{/if}
{#if isIndexed}
<span
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Currently indexed by PhotoPrism"
>
Indexed
</span>
{/if}
</div>
<div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={file.path}
> >
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
</div>
</button> </button>
{/each} </header>
<!-- Radio-style path list — the actual decision surface. -->
<div class="space-y-1">
{#each group.files as file, i (file.path)}
{@const isKeep = file.path === keep}
{@const isIndexed = file.path === group.indexedPath}
{@const parts = highlightDiff(file.path)}
{@const age = relAge(file.modTime, newestMs)}
<button
type="button"
onclick={() => (keep = file.path)}
class="flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors
{isKeep
? 'border-blue-500 bg-blue-500/10'
: 'border-transparent bg-secondary/50 hover:bg-secondary'}"
>
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold
{isKeep
? 'border-blue-500 bg-blue-500 text-white'
: 'border-muted-foreground/40 text-muted-foreground'}"
>
{isKeep ? '' : i + 1}
{#if isKeep}<Check class="h-2.5 w-2.5" />{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-xs">
<span class="text-muted-foreground">{parts.prefix}</span><span
class="font-semibold text-foreground"
>{parts.diff}</span
>
</span>
<span class="flex shrink-0 items-center gap-1.5 text-[10px]">
{#if isIndexed}
<span
class="rounded bg-emerald-600 px-1.5 py-0.5 font-semibold text-white"
title="Currently in the library — losing this moves the indexed copy"
>
Indexed
</span>
{/if}
{#if age}
<span class="flex items-center gap-0.5 text-muted-foreground" title={file.modTime}>
<Clock class="h-2.5 w-2.5" />{age}
</span>
{/if}
</span>
</button>
{/each}
</div>
{#if group.files.some((f) => f.path === group.indexedPath && f.path !== keep)}
<p class="text-[10px] text-amber-500">
Keeping a non-indexed copy — the indexed one will be archived; the indexer picks up the
survivor on its next pass.
</p>
{/if}
</div> </div>
</div> </div>

View File

@@ -1,24 +1,26 @@
<!-- <!--
Duplicate-resolution page body. Two panels driven by the parent Duplicate-resolution queue. Two panels driven by the parent route's
route's `activeTab` prop (URL-bound): `activeTab` prop (URL-bound):
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live 1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; listed via
`stack:true` and resolve via `setPrimary` + `deleteFile`. `stack:true`, resolved via resolveStack() (setPrimary + quarantine).
2. Cross-folder — files PhotoPrism silently rejected at index time 2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism because they were byte-identical to an existing entry. Scanned via
never adds those rows to its DB, so we scan the filesystem via the the sidecar's filesystem walk, resolved via resolveCrossFolder()
mule-sidecar. Resolution moves the unwanted copies into a (quarantine).
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
Both panels share one interaction model — a resolve-and-advance
queue: ↑/↓ or j/k rove between groups (scrollIntoView), resolving a
group removes it optimistically and auto-advances focus to whatever
now occupies that slot, so the whole queue clears without touching
the mouse. A sticky header tracks reclaimable bytes and a running
"resolved this session" tally.
The cross-folder scan auto-fires when its tab is active — with size The cross-folder scan auto-fires when its tab is active — with size
pre-filtering it stays fast (~250ms for 400 files in practice) and a pre-filtering it stays fast (~250ms for 400 files in practice) and a
long staleTime keeps tab bounces from re-running it. The button is long staleTime keeps tab bounces from re-running it.
kept for manual "rescan after I moved files" refreshes.
Tabs themselves render in the parent route's Toolbar so they line up
visually with the `/tags` pill row.
--> -->
<script lang="ts"> <script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -28,8 +30,20 @@
type CrossFolderScanResult type CrossFolderScanResult
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import {
dupSession,
formatBytes,
resolvedCrossHashes,
resolvedStackUids
} from '$lib/services/duplicateActions.svelte';
import { userLibraryBase } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
import { toggleShortcuts, view } from '$lib/stores/view.svelte';
import { popAndRun } from '$lib/stores/undo.svelte';
import StackGroupCard from './StackGroupCard.svelte'; import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte'; import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy, HardDrive } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder'; type Tab = 'stacks' | 'cross-folder';
@@ -48,102 +62,242 @@
// "Rescan filesystem" button invalidates to force a re-scan after // "Rescan filesystem" button invalidates to force a re-scan after
// the user has moved files around. // the user has moved files around.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({ const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: activeTab === 'cross-folder', enabled: activeTab === 'cross-folder',
staleTime: 5 * 60_000 staleTime: 5 * 60_000
})); }));
function rescan() { function rescan() {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] }); void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] });
} }
$effect(() => { $effect(() => {
if (crossQuery.error) { if (crossQuery.error) {
toast.error( toast.error(
crossQuery.error instanceof Error crossQuery.error instanceof Error ? crossQuery.error.message : 'Duplicates scan failed'
? crossQuery.error.message
: 'Cross-folder scan failed'
); );
} }
}); });
const crossCount = $derived(crossQuery.data?.groups.length ?? 0); // Filter out groups resolved this session but not yet reflected by a
// server refetch (PhotoPrism's cleanup reindex is async) — otherwise
// a background refetch could resurrect a group the user just cleared.
const liveStackGroups = $derived(groups.filter((g) => !resolvedStackUids.has(g.photo.UID)));
const liveCrossGroups = $derived(
(crossQuery.data?.groups ?? []).filter((g) => !resolvedCrossHashes.has(g.hash))
);
const activeGroups = $derived(activeTab === 'stacks' ? liveStackGroups : liveCrossGroups);
// Reclaimable bytes across everything still in the queue.
const reclaimableBytes = $derived(
activeTab === 'stacks'
? liveStackGroups.reduce((sum, g) => {
const keeperSize = Math.max(...g.files.map((f) => f.Size ?? 0));
const total = g.files.reduce((s, f) => s + (f.Size ?? 0), 0);
return sum + (total - keeperSize);
}, 0)
: liveCrossGroups.reduce((sum, g) => sum + g.size * (g.files.length - 1), 0)
);
// ── Roving focus + progressive rendering ───────────────────────────
let focusedIndex = $state(0);
let renderCount = $state(30);
// Reset when the tab or the underlying list identity changes size
// class (e.g. switching tabs, or a fresh scan lands).
$effect(() => {
void activeTab;
focusedIndex = 0;
renderCount = 30;
});
function clampFocus() {
if (activeGroups.length === 0) return;
focusedIndex = Math.min(focusedIndex, activeGroups.length - 1);
}
$effect(clampFocus);
function extend() {
renderCount = Math.min(activeGroups.length, renderCount + 30);
}
function moveFocus(delta: number) {
if (activeGroups.length === 0) return;
focusedIndex = Math.min(Math.max(0, focusedIndex + delta), activeGroups.length - 1);
if (focusedIndex >= renderCount) renderCount = Math.min(activeGroups.length, focusedIndex + 10);
}
async function onQueueKeydown(e: KeyboardEvent) {
if (view.shortcutsOpen) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault();
toggleShortcuts();
}
return;
}
// gridKeyNav (which normally owns ⌘Z) isn't mounted on these tabs —
// wire undo here so resolving a group is reversible without
// switching to a cause tab first.
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
const entry = await popAndRun();
toast[entry ? 'success' : 'message'](entry ? `Undone: ${entry.label}` : 'Nothing to undo');
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Group cards call stopPropagation on the keys they own (arrows
// L/R, digits, Enter, Space) — only j/k/ArrowUp/ArrowDown/? reach
// here, which is exactly the group-navigation contract.
switch (e.key) {
case 'ArrowUp':
case 'k':
e.preventDefault();
moveFocus(-1);
return;
case 'ArrowDown':
case 'j':
e.preventDefault();
moveFocus(1);
return;
case '?':
e.preventDefault();
toggleShortcuts();
return;
}
}
/** A group resolved — hold focus at the same index (the next group
* slides up into it) unless we were at the end. */
function onGroupResolved() {
if (focusedIndex >= activeGroups.length - 1) {
focusedIndex = Math.max(0, activeGroups.length - 2);
}
}
const crossCount = $derived(liveCrossGroups.length);
</script> </script>
<!-- Stacks tab ----------------------------------------------------- --> <!-- Sticky progress header — shared by both tabs -->
{#if activeTab === 'stacks'} <div
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6"> class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background/95 px-6 py-2.5 backdrop-blur"
{#if pending} >
<p class="text-sm text-muted-foreground">Loading stacks…</p> <div class="flex items-center gap-4 text-xs text-muted-foreground">
{:else if error} <span class="font-medium text-foreground">
<p class="text-sm text-destructive"> {activeGroups.length}
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'} {activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'}
</p> </span>
{:else if groups.length === 0} {#if reclaimableBytes > 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> <span class="flex items-center gap-1">
<p>No stacks.</p> <HardDrive class="h-3 w-3" />
<p class="text-xs"> {formatBytes(reclaimableBytes)} reclaimable
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have </span>
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index {/if}
time live under the Cross-folder tab. {#if dupSession.resolved > 0}
</p> <span class="text-emerald-500">
</div> Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session
{:else} </span>
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if} {/if}
</div> </div>
{/if} {#if activeTab === 'cross-folder'}
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={rescan}
>
{crossQuery.isFetching ? 'Scanning…' : 'Rescan filesystem'}
</button>
{/if}
</div>
<!-- Cross-folder tab ----------------------------------------------- --> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#if activeTab === 'cross-folder'} <div onkeydown={onQueueKeydown}>
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6"> <!-- Stacks tab ----------------------------------------------------- -->
<header class="flex items-baseline justify-between gap-3"> {#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<InlineLoader label="Loading stacks…" />
{:else if error}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if liveStackGroups.length === 0}
<EmptyState icon={Copy} title="No stacks">
{#snippet descriptionSnippet()}
<p>
The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies dropped at index time live under
the Duplicates tab.
</p>
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each liveStackGroups.slice(0, renderCount) as group, i (group.photo.UID)}
<StackGroupCard
{group}
focused={i === focusedIndex}
onFocusRequest={() => (focusedIndex = i)}
onResolved={onGroupResolved}
/>
{/each}
</div>
{#if renderCount < liveStackGroups.length}
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
{/if}
{/if}
</div>
{/if}
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
<p class="text-[11px] text-muted-foreground"> <p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by scanning the Byte-identical files the indexer dropped at index time. Found by scanning the originals
originals tree directly. tree directly.
</p> </p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={rescan}
>
{#if crossQuery.isFetching}
Scanning…
{:else}
Rescan filesystem
{/if}
</button>
</header>
{#if crossQuery.isFetching && !crossQuery.data} {#if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">Hashing files under originals…</p> <InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError} {:else if crossQuery.isError}
<p class="text-sm text-destructive"> <EmptyState
Scan failed: {crossQuery.error instanceof Error tone="destructive"
? crossQuery.error.message icon={AlertCircle}
: 'unknown error'} title="Scan failed"
</p> description={crossQuery.error instanceof Error
{:else if crossCount === 0} ? crossQuery.error.message
<p class="text-sm text-muted-foreground"> : 'unknown error'}
No cross-folder duplicates found. />
{#if crossQuery.data} {:else if crossCount === 0}
<span class="ml-1 text-[10px] text-muted-foreground/70"> <EmptyState icon={CheckCircle2} title="No duplicates found">
(scanned in {crossQuery.data.scannedMs} ms) {#snippet descriptionSnippet()}
</span> {#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
</p>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each liveCrossGroups.slice(0, renderCount) as group, i (group.hash)}
<CrossFolderGroupCard
{group}
focused={i === focusedIndex}
onFocusRequest={() => (focusedIndex = i)}
onResolved={onGroupResolved}
/>
{/each}
</div>
{#if renderCount < liveCrossGroups.length}
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
{/if} {/if}
</p> {/if}
{:else} </div>
<div class="space-y-3"> {/if}
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)} </div>
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}

View File

@@ -1,101 +1,90 @@
<!-- <!--
One duplicate stack rendered as a card. Each variant file is a clickable One duplicate stack rendered as a card. Each variant file is a tile;
tile; clicking selects it as the candidate "best". Committing promotes the selected one is the "keeper". Committing promotes the keeper to
the selected file to Primary (via `setPrimary`) and deletes the rest from Primary and moves every other file into the sidecar's `.duplicates/`
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/ quarantine (recoverable, undoable via ⌘Z) — resolution logic lives in
files/:fid` route). services/duplicateActions.svelte.ts.
Why DELETE instead of unstack-then-archive (which the plan started with): Keyboard (card scope — ↑/↓/j/k are NOT consumed here; they bubble to
PhotoPrism's `/unstack` returns `only originals can be unstacked` for DuplicatesView's group navigation):
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV - ←/→ move the keeper highlight; 19 jump straight to a file.
pairs. DELETE works for all of them — and cascades through the live- - Space opens the fullscreen compare lightbox (zoom-preserving flips).
photo group automatically, so one click resolves the whole stack. The - Enter resolves: keep selected, quarantine the rest.
on-disk file is renamed with a hash suffix (not erased), so a future
manual reindex can recover it if needed.
Keyboard: The fact rows under each thumb highlight the best value per column
- Section is tabindex=0; focusing it captures arrow keys + Enter. (largest size, highest resolution) so the winning file is obvious at
- Left/Right move the "best" highlight one file; Up/Down move by the a glance; a file that wins everything gets a "Suggested" badge.
grid's computed column count (same trick the timeline uses for
cross-row arrow nav).
- Enter commits the current selection. Esc removes focus from the card.
- The page's first card auto-focuses on mount so the user can drive
the workflow keyboard-first.
--> -->
<script lang="ts"> <script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query'; import { resolveStack } from '$lib/services/duplicateActions.svelte';
import { toast } from 'svelte-sonner';
import { deleteFile, setPrimary } from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte'; import { thumbUrl } from '$lib/stores/session.svelte';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { view } from '$lib/stores/view.svelte'; import type { PpFile } from '$lib/types/photoprism';
import CompareLightbox from './CompareLightbox.svelte';
import { Maximize2 } from 'lucide-svelte';
interface Props { interface Props {
group: DuplicateGroup; group: DuplicateGroup;
/** When true, the section auto-focuses on mount so the user can /** Roving focus — DuplicatesView owns which card is active. */
* arrow-key/Enter the workflow without reaching for the mouse. focused?: boolean;
* Only the page's first card should get this. */ /** Card was clicked/focused by pointer: tell the view to move its
autoFocus?: boolean; * roving index here. */
onFocusRequest?: () => void;
/** Resolve succeeded — view advances focus to the next group. */
onResolved?: () => void;
} }
let { group, autoFocus = false }: Props = $props(); let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
const qc = useQueryClient();
let best = $state(''); let best = $state('');
let busy = $state(false); let busy = $state(false);
let compareOpen = $state(false);
let sectionEl: HTMLElement | undefined = $state(); let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
$effect(() => { $effect(() => {
// Seed / re-seed `best` from the prop when the underlying group // Seed / re-seed `best` from the prop when the underlying group
// changes (keyed each + UID key normally keeps this stable, but // changes; the guard keeps user clicks intact across prop swaps.
// the guard handles prop swaps without overwriting user clicks).
if (!best || !group.files.some((f) => f.UID === best)) { if (!best || !group.files.some((f) => f.UID === best)) {
best = group.bestFileUid; best = group.bestFileUid;
} }
}); });
$effect(() => { $effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true }); if (focused && sectionEl && !compareOpen) {
sectionEl.focus({ preventScroll: true });
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}); });
// Track the grid's column count via ResizeObserver — same approach // ── Comparison facts ────────────────────────────────────────────────
// the timeline uses. Reading `gridTemplateColumns` from computed const maxSize = $derived(Math.max(...group.files.map((f) => f.Size ?? 0)));
// style is O(1) regardless of how many tiles render. const maxPixels = $derived(Math.max(...group.files.map((f) => pixels(f))));
$effect(() => { const sizesDiffer = $derived(new Set(group.files.map((f) => f.Size ?? 0)).size > 1);
if (!gridEl) return; const pixelsDiffer = $derived(new Set(group.files.map((f) => pixels(f))).size > 1);
const measure = () => { /** UID of the file that wins on every differing axis, if unique. */
if (!gridEl) return; const suggestedUid = $derived.by(() => {
const n = getComputedStyle(gridEl) const winners = group.files.filter(
.gridTemplateColumns.split(' ') (f) =>
.filter(Boolean).length; (!sizesDiffer || (f.Size ?? 0) === maxSize) &&
cols = Math.max(1, n); (!pixelsDiffer || pixels(f) === maxPixels)
}; );
measure(); return winners.length === 1 && (sizesDiffer || pixelsDiffer) ? winners[0].UID : null;
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
// thumbnailSize changes alter cols without resizing the grid; re-
// measure on the next microtask.
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
}); });
function pixels(f: PpFile): number {
return (f.Width ?? 0) * (f.Height ?? 0);
}
function typeBadge(f: PpFile): string {
return (f.FileType ?? f.Name?.split('.').pop() ?? '').toUpperCase();
}
function shortPath(name: string): string { function shortPath(name: string): string {
const segs = name.split('/').filter(Boolean); const segs = name.split('/').filter(Boolean);
if (segs.length <= 2) return name; if (segs.length <= 2) return name;
return '…/' + segs.slice(-2).join('/'); return '…/' + segs.slice(-2).join('/');
} }
function dims(f: { Width?: number; Height?: number }): string { function dims(f: PpFile): string {
if (!f.Width || !f.Height) return ''; if (!f.Width || !f.Height) return '';
return `${f.Width}×${f.Height}`; return `${f.Width}×${f.Height}`;
} }
@@ -114,87 +103,57 @@
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (busy) return; if (busy || compareOpen) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) { switch (e.key) {
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
moveBest(-1); moveBest(-1);
return; return;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
moveBest(1); moveBest(1);
return; return;
case 'ArrowUp': case ' ':
e.preventDefault(); e.preventDefault();
moveBest(-cols); e.stopPropagation();
return; compareOpen = true;
case 'ArrowDown':
e.preventDefault();
moveBest(cols);
return; return;
case 'Enter': case 'Enter':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
void commit(); void commit();
return; return;
case 'Escape': default: {
(e.target as HTMLElement)?.blur(); const n = Number.parseInt(e.key, 10);
return; if (n >= 1 && n <= group.files.length) {
e.preventDefault();
e.stopPropagation();
best = group.files[n - 1].UID;
}
}
} }
} }
async function commit() { async function commit() {
if (busy || group.files.length < 2) return; if (busy || group.files.length < 2) return;
busy = true; busy = true;
const photoUid = group.photo.UID;
const losers = group.files.filter((f) => f.UID !== best);
try { try {
// 1. Promote the user's pick to Primary first (idempotent — if const ok = await resolveStack(group, best);
// it's already Primary, the call is a no-op on the server). if (ok) onResolved?.();
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
if (best !== currentPrimary) {
await setPrimary(photoUid, best);
}
// 2. Delete each non-best file. PhotoPrism cascades through
// related variants in the same logical group (live-photo
// pairs, sidecar companions), so a single DELETE on one
// HEIC variant clears the whole HEIC+MOV pair in one go.
// Loop tolerates partial success — if PhotoPrism already
// cleared the file via cascade, the next DELETE 404s and
// we move on.
for (const f of losers) {
try {
await deleteFile(photoUid, f.UID);
} catch (err) {
// 404 means the file's already gone (cascade) — fine.
// Any other status means we have a real problem; bubble it.
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status !== 404) throw err;
}
}
toast.success(`Resolved · kept 1 of ${group.files.length}`);
void qc.invalidateQueries({ queryKey: ['duplicates'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
const msg =
err instanceof Error && err.message ? err.message : 'Resolve failed';
toast.error(msg);
} finally { } finally {
busy = false; busy = false;
} }
} }
</script> </script>
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
none` because we paint our own focus ring on .focus-visible below
(otherwise the browser default outline would clash with the tile
selection ring). -->
<!-- <!--
`role="application"` declares this as a custom keyboard widget (arrow `role="application"` declares this as a custom keyboard widget (arrow
keys + Enter, not standard reading order). The element below is a keys + Enter, not standard reading order). `<div>` rather than
`<div>` rather than `<section>` because Svelte's a11y linter treats `<section>` because Svelte's a11y linter treats `<section>` as
`<section>` as strictly non-interactive even with an explicit strictly non-interactive even with an explicit application role.
application role.
--> -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -202,10 +161,11 @@
bind:this={sectionEl} bind:this={sectionEl}
tabindex="0" tabindex="0"
role="application" role="application"
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`} aria-label={`Duplicate stack of ${group.files.length} files — ←/→ pick the keeper, Space compares, Enter resolves`}
onkeydown={onKeydown} onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none onfocusin={() => onFocusRequest?.()}
focus-visible:ring-2 focus-visible:ring-primary/50" class="space-y-2 rounded-md border bg-card/30 p-3 outline-none transition-colors
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
> >
<header class="flex items-center justify-between gap-3"> <header class="flex items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
@@ -216,74 +176,115 @@
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''} {group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
</div> </div>
</div> </div>
<button <div class="flex shrink-0 items-center gap-2">
type="button" <button
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50" type="button"
disabled={busy || group.files.length < 2} class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
onclick={commit} disabled={busy}
title="Promote the selected file and delete the rest from this stack" onclick={() => (compareOpen = true)}
> title="Compare candidates fullscreen (zoom-preserving flips)"
Keep selected, delete rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
> >
</button> <Maximize2 class="h-3 w-3" /> Compare
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Space</kbd
>
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Promote the selected file; the rest move to the recoverable .duplicates/ quarantine"
>
Keep selected
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</div>
</header> </header>
<div <div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));">
bind:this={gridEl} {#each group.files as file, i (file.UID)}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.UID)}
{@const isBest = file.UID === best} {@const isBest = file.UID === best}
{@const sizeStr = sizeLabel(file.Size)} {@const sizeStr = sizeLabel(file.Size)}
{@const bestSize = sizesDiffer && (file.Size ?? 0) === maxSize}
{@const bestRes = pixelsDiffer && pixels(file) === maxPixels && pixels(file) > 0}
<button <button
type="button" type="button"
onclick={() => (best = file.UID)} onclick={() => (best = file.UID)}
class:scale-95={isBest} ondblclick={() => {
best = file.UID;
compareOpen = true;
}}
class:ring-2={isBest} class:ring-2={isBest}
class:ring-blue-500={isBest} class:ring-blue-500={isBest}
class:ring-offset-2={isBest} class:ring-offset-2={isBest}
class:ring-offset-background={isBest} class:ring-offset-background={isBest}
class:transition-[transform,box-shadow]={isBest} class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none transition-shadow focus:outline-none"
class:duration-300={isBest}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
> >
<div class="relative aspect-square w-full overflow-hidden"> <div class="relative aspect-square w-full overflow-hidden">
<img <img
src={thumbUrl(file.Hash, 'tile_500')} src={thumbUrl(file.Hash, 'tile_500')}
alt={file.Name} alt={file.Name}
loading="lazy" loading="lazy"
decoding="async"
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{#if isBest} {#if isBest}
<span <span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white" class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
> >
Best Keep
</span> </span>
{/if} {:else if file.UID === suggestedUid}
{#if dims(file)}
<span <span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground" class="absolute left-1.5 top-1.5 rounded bg-emerald-600/90 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Largest and highest-resolution file in this stack"
> >
{dims(file)} Suggested
</span> </span>
{/if} {/if}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/90"
>
{i + 1}
</span>
</div> </div>
<div <div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground" class="flex flex-col gap-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`} title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
> >
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div> <div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
{#if sizeStr} <div class="flex items-center gap-1.5">
<div>{sizeStr}</div> {#if typeBadge(file)}
{/if} <span class="rounded bg-muted px-1 py-px font-medium">{typeBadge(file)}</span>
{/if}
{#if dims(file)}
<span class={bestRes ? 'font-semibold text-emerald-500' : ''}>{dims(file)}</span>
{/if}
{#if sizeStr}
<span class={bestSize ? 'font-semibold text-emerald-500' : ''}>{sizeStr}</span>
{/if}
</div>
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{#if compareOpen}
<CompareLightbox
files={group.files}
startUid={best}
onPick={(uid) => {
best = uid;
compareOpen = false;
sectionEl?.focus({ preventScroll: true });
}}
onClose={() => {
compareOpen = false;
sectionEl?.focus({ preventScroll: true });
}}
/>
{/if}

View File

@@ -0,0 +1,84 @@
<!--
Shared empty / no-data placeholder. Doubles as an error display when
`tone="destructive"` (swaps colors and announces with role=alert).
Use `size="compact"` inside sidebars where vertical space is tight.
-->
<script lang="ts">
import type { Component, Snippet } from 'svelte';
interface Props {
icon?: Component<any> | any;
title: string;
description?: string;
descriptionSnippet?: Snippet;
align?: 'left' | 'center';
tone?: 'muted' | 'destructive';
size?: 'compact' | 'default';
children?: Snippet;
}
let {
icon: Icon,
title,
description,
descriptionSnippet,
align,
tone = 'muted',
size = 'default',
children
}: Props = $props();
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
const isDestructive = $derived(tone === 'destructive');
</script>
{#if size === 'compact'}
<div
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
? 'items-center justify-center text-center'
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
{/if}
<div class="min-w-0">
<span>{title}</span>
{#if descriptionSnippet}
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
{:else if description}
<div class="mt-0.5 opacity-80">{description}</div>
{/if}
{#if children}
<div class="mt-1.5">{@render children()}</div>
{/if}
</div>
</div>
{:else}
<div
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
? 'items-center text-center'
: 'items-start text-left'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
aria-hidden="true"
/>
{/if}
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
{#if descriptionSnippet}
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
{@render descriptionSnippet()}
</div>
{:else if description}
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
{/if}
{#if children}
<div class="mt-2">{@render children()}</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,39 @@
<!--
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
in sidebars, popovers, and right rails. For the initial photo-grid load,
use SkeletonGrid instead (layout-preserving).
-->
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
interface Props {
label?: string;
size?: 'sm' | 'default';
align?: 'left' | 'center';
srOnly?: boolean;
polite?: boolean;
}
let {
label = 'Loading…',
size = 'default',
align = 'left',
srOnly = false,
polite = true
}: Props = $props();
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
</script>
<p
role="status"
aria-busy="true"
aria-live={polite ? 'polite' : 'off'}
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
>
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
</p>

View File

@@ -0,0 +1,2 @@
export { default as EmptyState } from './EmptyState.svelte';
export { default as InlineLoader } from './InlineLoader.svelte';

View File

@@ -0,0 +1,200 @@
<!--
⌘K command palette. Jump to any section, heap, folder, or tag category,
plus a few global actions (dark mode, shortcut overlay). Data comes from
the same TanStack queries the sidebar already keeps warm (['heaps'],
['folders', …]), so opening the palette costs no extra fetches once the
app has booted. bits-ui's Command owns filtering and keyboard selection.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Command, Dialog } from 'bits-ui';
import { createQuery } from '@tanstack/svelte-query';
import { toggleMode } from 'mode-watcher';
import {
Archive,
EyeOff,
Folder,
Image,
Keyboard,
Layers,
ListChecks,
Moon,
NotebookPen,
Tags,
Users
} from 'lucide-svelte';
import { listFolders, listHeaps, type PpAlbum, type PpFolder } from '$lib/services/photoprism';
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
import { closePalette, toggleShortcuts, view } from '$lib/stores/view.svelte';
const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'],
queryFn: listHeaps,
enabled: isAuthenticated() && view.paletteOpen
}));
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders', userLibraryBase()],
queryFn: listFolders,
staleTime: 30_000,
enabled: isAuthenticated() && view.paletteOpen
}));
function run(fn: () => void) {
closePalette();
fn();
}
const go = (path: string) => () => run(() => void goto(path));
interface Entry {
label: string;
icon: typeof Image;
action: () => void;
}
const SECTIONS: Entry[] = [
{ label: 'All photos', icon: Image, action: go('/') },
{ label: 'Review queue', icon: ListChecks, action: go('/review') },
{ label: 'Archive', icon: Archive, action: go('/?section=archive') },
{ label: 'Hidden', icon: EyeOff, action: go('/?section=hidden') },
{ label: 'Notes', icon: NotebookPen, action: go('/notes') },
{ label: 'Tags', icon: Tags, action: go('/tags/labels') },
{ label: 'People', icon: Users, action: go('/tags/people') },
{ label: 'Duplicates', icon: Layers, action: go('/review?tab=stacks') }
];
const ACTIONS: Entry[] = [
{ label: 'Toggle dark mode', icon: Moon, action: () => run(toggleMode) },
{ label: 'Keyboard shortcuts', icon: Keyboard, action: () => run(toggleShortcuts) }
];
// Folders can number in the hundreds; the palette lists them all and
// lets Command's fuzzy filter narrow. Sorted shallow-first so top-level
// folders surface before deep ones on an empty query.
const folderEntries = $derived(
[...(foldersQuery.data ?? [])]
.sort(
(a, b) =>
a.Path.split('/').length - b.Path.split('/').length || a.Path.localeCompare(b.Path)
)
.slice(0, 400)
);
</script>
<Dialog.Root
open={view.paletteOpen}
onOpenChange={(o) => {
if (!o) closePalette();
}}
>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm" />
<Dialog.Content
class="fixed left-1/2 top-24 z-[81] w-[min(560px,92vw)] -translate-x-1/2 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl"
aria-label="Command palette"
>
<Command.Root class="flex max-h-[60vh] flex-col">
<Command.Input
class="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
placeholder="Jump to a view, heap, or folder…"
/>
<Command.List class="overflow-y-auto p-1.5">
<Command.Viewport>
<Command.Empty class="px-3 py-6 text-center text-xs text-muted-foreground">
No matches.
</Command.Empty>
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Go to
</Command.GroupHeading>
<Command.GroupItems>
{#each SECTIONS as s (s.label)}
<Command.Item
value={s.label}
onSelect={s.action}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<s.icon class="h-3.5 w-3.5 text-muted-foreground" />
{s.label}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{#if (heapsQuery.data ?? []).length > 0}
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Heaps
</Command.GroupHeading>
<Command.GroupItems>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
<Command.Item
value={`heap ${heap.Title}`}
onSelect={go(`/?section=heap&heap=${heap.UID}`)}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<Layers class="h-3.5 w-3.5 text-muted-foreground" />
{heap.Title}
{#if heap.PhotoCount}
<span class="ml-auto text-[10px] text-muted-foreground">
{heap.PhotoCount}
</span>
{/if}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{#if folderEntries.length > 0}
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Folders
</Command.GroupHeading>
<Command.GroupItems>
{#each folderEntries as folder (folder.Path)}
<Command.Item
value={`folder ${folder.Path}`}
onSelect={go(`/?folder=${encodeURIComponent(folder.Path)}`)}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<Folder class="h-3.5 w-3.5 text-muted-foreground" />
<span class="truncate">{folder.Path}</span>
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
<Command.Group>
<Command.GroupHeading
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Actions
</Command.GroupHeading>
<Command.GroupItems>
{#each ACTIONS as a (a.label)}
<Command.Item
value={a.label}
onSelect={a.action}
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
>
<a.icon class="h-3.5 w-3.5 text-muted-foreground" />
{a.label}
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
</Command.Viewport>
</Command.List>
</Command.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -40,7 +40,8 @@
<script lang="ts"> <script lang="ts">
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment'; 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 Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte'; import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -49,10 +50,13 @@
depth?: number; depth?: number;
onPick: (path: string) => void; onPick: (path: string) => void;
/** Mutating callbacks are only required when readonly !== true. The /** 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; onRename?: (path: string) => void;
onDelete?: (path: string) => void; onDelete?: (path: string) => void;
onCreateChild?: (parent: 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 /** Read-only mode: hides the kebab menu and disables double-click
* rename, so the tree can be reused as a folder picker. */ * rename, so the tree can be reused as a folder picker. */
readonly?: boolean; readonly?: boolean;
@@ -65,6 +69,10 @@
* "{n} photos" affordance. Undefined keeps the badge off entirely * "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */ * (the picker dialog doesn't need it). */
counts?: Record<string, number>; counts?: Record<string, number>;
/** Render every branch expanded regardless of the persisted openSet —
* the picker turns this on while a search filter is active so matches
* buried in collapsed branches stay visible. */
forceExpand?: boolean;
} }
let { let {
nodes, nodes,
@@ -73,9 +81,11 @@
onRename, onRename,
onDelete, onDelete,
onCreateChild, onCreateChild,
onMove,
readonly = false, readonly = false,
selectedPath, selectedPath,
counts counts,
forceExpand = false
}: Props = $props(); }: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state // Auto-expanded folders, persisted to localStorage so the tree state
@@ -105,11 +115,42 @@
if (selectedPath !== undefined) return selectedPath === path; if (selectedPath !== undefined) return selectedPath === path;
return filters.folderPath === 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> </script>
<ul> <ul>
{#each nodes as node (node.path)} {#each nodes as node (node.path)}
{@const open = openSet.has(node.path)} {@const open = forceExpand || openSet.has(node.path)}
{@const active = isActive(node.path)} {@const active = isActive(node.path)}
{@const hasChildren = node.children.length > 0} {@const hasChildren = node.children.length > 0}
<li> <li>
@@ -128,30 +169,38 @@
> >
{#if hasChildren} {#if hasChildren}
<button <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} class:text-muted-foreground={!active}
onclick={() => toggle(node.path)} onclick={() => toggle(node.path)}
title={open ? 'Collapse' : 'Expand'} title={open ? 'Collapse' : 'Expand'}
aria-label={open ? 'Collapse' : 'Expand'} aria-label={open ? 'Collapse' : 'Expand'}
> >
{open ? '▾' : '▸'} <ChevronRight
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
/>
</button> </button>
{:else} {:else}
<!-- Spacer keeps childless siblings aligned with their chevroned <!-- Spacer keeps childless siblings aligned with their chevroned
peers at every depth, so labels share a common left edge peers at every depth, so labels share a common left edge
across the sidebar (folders, heaps, views, manage). --> 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} {/if}
<!-- <!--
Count badge lives INSIDE the button so the entire row (label Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge was previously a dead + badge) is one hit target — the badge was previously a dead
zone right where the user's eye lands. zone right where the user's eye lands.
--> -->
<!-- In readonly (picker) mode the row carries data attributes the
move dialog uses for roving arrow-key focus, plus aria-pressed
so screen readers hear the current selection. -->
<button <button
class="flex min-w-0 flex-1 items-center pl-1 text-left" class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => onPick(node.path)} onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)} ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path} title={node.path}
data-move-row={readonly ? '' : undefined}
data-path={readonly ? node.path : undefined}
aria-pressed={readonly ? active : undefined}
> >
<span class="truncate">{node.name}</span> <span class="truncate">{node.name}</span>
{#if counts && counts[node.path] !== undefined} {#if counts && counts[node.path] !== undefined}
@@ -187,6 +236,13 @@
<Pencil class="h-3.5 w-3.5 text-muted-foreground" /> <Pencil class="h-3.5 w-3.5 text-muted-foreground" />
Rename Rename
</Item> </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" /> <Separator class="my-1 h-px bg-border" />
<Item <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" 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,9 +263,11 @@
{onRename} {onRename}
{onDelete} {onDelete}
{onCreateChild} {onCreateChild}
{onMove}
{readonly} {readonly}
{selectedPath} {selectedPath}
{counts} {counts}
{forceExpand}
/> />
{/if} {/if}
</li> </li>

View File

@@ -1,23 +1,21 @@
<!-- <!--
General app preferences. The UI tab owns the SvelteKit shell's General app preferences. Two tabs: the SvelteKit shell's
light/dark/system theme (mode-watcher) plus the per-user UI knobs light/dark/system theme (mode-watcher) and the signed-in user's account
PhotoPrism's /settings exposes. Search and Maps follow the same (identity + password change).
pattern — server prefs round-trip via /api/v1/settings.
The Library admin dialog and this one share the ['settings'] cache, PhotoPrism's own per-user UI/search/maps knobs used to live here too, but
so saves from either invalidate the other. 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"> <script lang="ts">
import { Dialog, Tabs } from 'bits-ui'; 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 { mode, setMode } from 'mode-watcher';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte'; import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import { import { setUserPassword } from '$lib/services/photoprism';
getSettings, import { session } from '$lib/stores/session.svelte';
saveSettings,
type PpSettings
} from '$lib/services/photoprism';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -25,9 +23,29 @@
} }
let { open, onClose }: Props = $props(); let { open, onClose }: Props = $props();
const qc = useQueryClient(); let activeTab = $state<'ui' | 'account'>('ui');
let activeTab = $state<'ui' | 'search' | 'maps'>('ui'); // ── Account tab — password change ─────────────────────────────────────
let pwOld = $state('');
let pwNew = $state('');
let pwConfirm = $state('');
const pwMut = createMutation(() => ({
mutationFn: async () => {
if (!session.user) throw new Error('Not signed in');
if (pwNew.length < 8) throw new Error('New password must be at least 8 characters');
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
await setUserPassword(session.user.UID, pwOld, pwNew);
},
onSuccess: () => {
pwOld = '';
pwNew = '';
pwConfirm = '';
toast.success('Password updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update password')
}));
const themeOptions = [ const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun }, { value: 'light', label: 'Light', Icon: Sun },
@@ -35,107 +53,6 @@
{ value: 'system', label: 'System', Icon: Monitor } { value: 'system', label: 'System', Icon: Monitor }
] as const; ] 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 = const selectClass =
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring'; 'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
</script> </script>
@@ -160,8 +77,8 @@
General settings General settings
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground"> <Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app and your PhotoPrism account. Preferences for Mulimage and your account. Library admin lives
Library admin lives under Folders → ⚙. under Folders → ⚙.
</Dialog.Description> </Dialog.Description>
</div> </div>
<Dialog.Close <Dialog.Close
@@ -174,7 +91,7 @@
<Tabs.Root bind:value={activeTab}> <Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border"> <Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps'] as const as t (t)} {#each ['ui', 'account'] as const as t (t)}
<Tabs.Trigger <Tabs.Trigger
value={t} 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" 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"
@@ -184,8 +101,7 @@
{/each} {/each}
</Tabs.List> </Tabs.List>
<!-- UI — local app theme (mode-watcher) on top, then the <!-- UI — local app theme (mode-watcher). Persists itself; no Save. -->
PhotoPrism per-user UI knobs that go to /settings. -->
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none"> <Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2"> <section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -215,176 +131,99 @@
Light/dark for this app. Persists locally; no Save needed. Light/dark for this app. Persists locally; no Save needed.
</p> </p>
</section> </section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
{:else if draft}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
PhotoPrism 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> </Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'} <!-- Account — reads from the session store and round-trips its own
<Tabs.Content value={activeTab} class="outline-none"> password mutation. -->
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p> <Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
</Tabs.Content> <section class="space-y-2">
{:else if settingsQuery.isError && activeTab !== 'ui'} <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
<Tabs.Content value={activeTab} class="outline-none"> Signed in as
<p class="px-1 text-[12px] text-destructive"> </h3>
Could not load settings. <div class="space-y-1 rounded border border-border bg-muted/30 p-2">
</p> <div class="flex justify-between gap-3">
</Tabs.Content> <span class="text-muted-foreground">Name</span>
{:else if draft} <span class="font-medium">{session.user?.Name ?? '—'}</span>
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none"> </div>
<label class="flex items-center gap-2"> {#if session.user?.DisplayName}
<input type="checkbox" bind:checked={draft.search!.listView} /> <div class="flex justify-between gap-3">
Default to list view <span class="text-muted-foreground">Display name</span>
</label> <span>{session.user.DisplayName}</span>
<label class="flex items-center gap-2"> </div>
<input type="checkbox" bind:checked={draft.search!.showTitles} /> {/if}
Show titles {#if session.user?.Email}
</label> <div class="flex justify-between gap-3">
<label class="flex items-center gap-2"> <span class="text-muted-foreground">Email</span>
<input type="checkbox" bind:checked={draft.search!.showCaptions} /> <span>{session.user.Email}</span>
Show captions </div>
</label> {/if}
<label class="flex flex-col gap-1"> <div class="flex justify-between gap-3">
<span class="text-muted-foreground"> <span class="text-muted-foreground">Role</span>
Batch size (-1 = server default) <span>{session.user?.Role ?? '—'}</span>
</span> </div>
<input </div>
type="number" </section>
bind:value={draft.search!.batchSize}
class={selectClass}
/>
</label>
</Tabs.Content>
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none"> <form
class="space-y-3"
onsubmit={(e) => {
e.preventDefault();
pwMut.mutate();
}}
>
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Change password
</h3>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-muted-foreground">Style</span> <span class="text-muted-foreground">Current password</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 <input
type="number" type="password"
bind:value={draft.maps!.animate} autocomplete="current-password"
bind:value={pwOld}
required
class={selectClass} class={selectClass}
/> />
</label> </label>
</Tabs.Content> <label class="flex flex-col gap-1">
{/if} <span class="text-muted-foreground">New password</span>
<input
type="password"
autocomplete="new-password"
bind:value={pwNew}
required
minlength={8}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Confirm new password</span>
<input
type="password"
autocomplete="new-password"
bind:value={pwConfirm}
required
minlength={8}
class={selectClass}
/>
</label>
<div class="flex justify-end">
<button
type="submit"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={pwMut.isPending ||
!pwOld ||
pwNew.length < 8 ||
pwNew !== pwConfirm}
>
{#if pwMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Update password
</button>
</div>
</form>
</Tabs.Content>
</Tabs.Root> </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. -->
{#if draft}
<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.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </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,245 +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, Loader2 } from 'lucide-svelte';
import {
convertHeap,
listFolders,
type HeapConvertBody,
type HeapConvertResult,
type PpAlbum,
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection } from '$lib/stores/filters.svelte';
import { isAuthenticated, 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}
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p>
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-[11px] text-muted-foreground">
No folders. Create one from the sidebar first.
</p>
{:else}
<!-- 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 @@
<!-- <!-- PhotoPrism indexer status pill. Driven by the indexer store, which
Compact status pill that appears in the header while PhotoPrism's subscribes to PhotoPrism's WS channel. Delegates rendering to StatusPill. -->
indexer is doing work. Driven by the indexer store, which subscribes
to PhotoPrism's WS channel. Renders nothing when idle so it never
steals header real estate from the user.
The `detail` (current path/file) is exposed via `title` rather than
rendered inline — the pill stays narrow even on slow flashes through
a deep library, and hover surfaces the detail for users who care.
-->
<script lang="ts"> <script lang="ts">
import { indexer } from '$lib/stores/indexer.svelte'; import { indexer } from '$lib/stores/indexer.svelte';
import { Loader2 } from 'lucide-svelte'; import StatusPill from './StatusPill.svelte';
// PhotoPrism's `fileName` arrives as the full relative path
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
// recognition; the full path stays in the `title` for users who hover.
const basename = $derived.by(() => {
const d = indexer.detail;
if (!d) return '';
const i = d.lastIndexOf('/');
return i >= 0 ? d.slice(i + 1) : d;
});
</script> </script>
{#if indexer.active || indexer.label} <StatusPill active={indexer.active} label={indexer.label} detail={indexer.detail} />
<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}

View File

@@ -7,62 +7,78 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import {
aggregateKeywords, aggregateKeywords,
countPhotos,
createFolder, createFolder,
createHeap, createHeap,
deleteFolder, deleteFolder,
deleteHeap, deleteHeap,
duplicateHeap, duplicateHeap,
getAllMarks, getIndexSubpath,
getConfig,
heapDownloadUrl, heapDownloadUrl,
listFolderCounts,
listFolders, listFolders,
listGeo,
listHeaps, listHeaps,
logout, logout,
renameFolder, renameFolder,
renameHeap, renameHeap,
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
startIndex,
triggerDownload, triggerDownload,
type AggregatedKeyword,
type CrossFolderScanResult, type CrossFolderScanResult,
type PhotoMarksMap,
type PpAlbum, type PpAlbum,
type PpClientConfig, type PpFolder
type PpFolder,
type PpGeoCollection
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { import {
listDuplicateGroups, listDuplicateGroups,
type DuplicateGroup type DuplicateGroup
} from '$lib/services/adapters/duplicates'; } from '$lib/services/adapters/duplicates';
import {
listReviewGroups,
type CauseKey,
type ReviewGroup
} from '$lib/services/adapters/review';
import { import {
filters, filters,
navigateToFolder,
setFolderPath, setFolderPath,
setSection, setSection,
TAG_CATEGORIES, TAG_CATEGORIES,
type Section, type Section,
type TagCategory type TagCategory
} from '$lib/stores/filters.svelte'; } 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 FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte'; import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte'; import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte'; import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import { import {
ChevronRight,
Copy, Copy,
Download, Download,
FolderInput, FolderInput,
FolderOpen,
FolderPlus, FolderPlus,
Layers,
LogOut, LogOut,
Moon, Moon,
Pencil, Pencil,
RefreshCw,
Settings, Settings,
Sun, Sun,
Trash2 Trash2,
Users
} from 'lucide-svelte'; } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -72,234 +88,59 @@
enabled: isAuthenticated() 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[]>(() => ({ const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'], queryKey: ['folders', userLibraryBase()],
queryFn: listFolders, 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(), enabled: isAuthenticated(),
staleTime: 60_000 staleTime: 30_000,
retry: 2,
refetchOnMount: 'always'
})); }));
function bucketCount( // Hydrate the per-user index sub-path into the session store on load so the
key: 'favorites' | 'review' | 'hidden' | 'archived', // Library tree re-roots to it without waiting for the settings dialog to be
query: { data: number | undefined; isPending: boolean } // opened. Shares the ['prefs'] key with SettingsDialog's setter.
): number | undefined { const prefsQuery = createQuery<string>(() => ({
if (wantScoped) { queryKey: ['prefs'],
if (query.isPending) return undefined; queryFn: getIndexSubpath,
return query.data;
}
// Admin + no BasePath: use the precomputed PhotoPrism counters
// (no extra round-trip).
const c = configQuery.data?.count;
if (!c) return undefined;
return c[key];
}
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(), 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 // Stacks + cross-folder duplicate caches are warmed here so the
// PhotoPrism query so we always fetch it; cross-folder is an // /duplicates view (and its review tab strip) hits a warm cache. The
// O(disk) scan, so the sidebar only *observes* its cache // sidebar only observes these — cross-folder is an O(disk) scan, so it
// (enabled:false) and the duplicates page itself is what populates // stays enabled:false and the duplicates page populates it on first visit.
// it on first visit. Both share queryKeys with the /duplicates
// view so cache is reused.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'], queryKey: ['duplicates', userLibraryBase()],
queryFn: listDuplicateGroups, queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(), enabled: isAuthenticated(),
staleTime: 60_000 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>(() => ({ const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: false, enabled: false,
staleTime: 5 * 60_000 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( const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path)) buildTree((foldersQuery.data ?? []).map((f) => f.Path))
); );
// Per-folder photo counts. PhotoPrism's /folders/originals reports // Gates admin-only entry points lower in the sidebar.
// FileCount: 0 for every folder, so the sidecar /folders/counts const isAdminUser = $derived(session.user?.Role === 'admin');
// 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
);
const createMut = createMutation(() => ({ const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title), mutationFn: (title: string) => createHeap(title),
@@ -337,9 +178,6 @@
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap') 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. // Library/admin settings dialog visibility.
let settingsOpen = $state(false); let settingsOpen = $state(false);
@@ -348,6 +186,10 @@
// admin dialog above — opened from the bottom-of-sidebar footer. // admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false); let generalSettingsOpen = $state(false);
// Admin-only user management dialog. Footer icon is gated on
// `isAdminUser` so non-admins never see the entry point.
let usersOpen = $state(false);
// Root-folder collapse state. Persisted to its own localStorage key so // Root-folder collapse state. Persisted to its own localStorage key so
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults // it doesn't collide with FolderTree's per-subfolder openSet. Defaults
// to open so first-time users see the full tree. // to open so first-time users see the full tree.
@@ -363,43 +205,46 @@
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0'); if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
} }
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded` // Cause-tab list is dynamic (only buckets with hits show up on /review),
// above (keeping it out of `view.metadataSections`, which is reserved for // so the sidebar mirrors that by reusing the same query. The queryKey is
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar // shared with the /review page so visiting that route warms the cache for free.
// doesn't grow on first paint. const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
const TAGS_OPEN_KEY = 'mule_tags_expanded'; queryKey: ['review-groups'],
let tagsExpanded = $state(loadTagsExpanded()); queryFn: listReviewGroups,
function loadTagsExpanded(): boolean { enabled: isAuthenticated(),
if (!browser) return false; staleTime: 30_000
const raw = localStorage.getItem(TAGS_OPEN_KEY); }));
return raw === '1';
} type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
function toggleTags() { // Stacks + Duplicates are always present on the /review tab strip
tagsExpanded = !tagsExpanded; // regardless of count (the cross-folder scan is lazy from its own
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0'); // 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> = { const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels', labels: 'Labels',
keywords: 'Keywords', keywords: 'Keywords',
people: 'People',
colors: 'Colors', 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;
if (cat === 'ratings') return ratingsCount;
return colorsCount;
}
function isTagCategoryActive(cat: TagCategory): boolean { function isTagCategoryActive(cat: TagCategory): boolean {
return page.url.pathname.startsWith(`/tags/${cat}`); return page.url.pathname.startsWith(`/tags/${cat}`);
} }
@@ -426,6 +271,17 @@
session.user?.DisplayName?.trim() || session.user?.Name || '/' 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() { async function onSignOut() {
await logout(); await logout();
await goto('/login', { replaceState: true }); await goto('/login', { replaceState: true });
@@ -442,10 +298,15 @@
} }
const createFolderMut = createMutation(() => ({ 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) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`); toast.success(`Folder created: ${toUserPath(r.path)}`);
}, },
onError: (err) => onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder') toast.error(err instanceof Error ? err.message : 'Could not create folder')
@@ -453,36 +314,59 @@
const renameFolderMut = createMutation(() => ({ const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) => mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName), renameFolder(toOriginalsPath(args.rel), args.newName),
onSuccess: (r) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] }); 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 the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) { if (filters.folderPath === oldUi) {
setFolderPath(r.newPath); setFolderPath(newUi);
const params = new URLSearchParams({ folder: r.newPath }); const params = new URLSearchParams({ folder: newUi });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true }); void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
} }
toast.success(`Renamed: ${r.oldPath}${r.newPath}`); toast.success(`Renamed: ${oldUi}${newUi}`);
}, },
onError: (err) => onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed') toast.error(err instanceof Error ? err.message : 'Rename failed')
})); }));
const deleteFolderMut = createMutation(() => ({ const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel), mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
onSuccess: (r) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] }); 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); setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true }); void goto('/', { keepFocus: true, noScroll: true });
} }
toast.success(`Folder deleted: ${r.path}`); toast.success(`Folder deleted: ${ui}`);
}, },
onError: (err) => onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Delete failed') 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) { function onCreateFolder(parent: string | null = null) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim(); const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return; if (!name) return;
@@ -504,14 +388,7 @@
} }
async function pickFolder(folderPath: string) { async function pickFolder(folderPath: string) {
// Folder selection works on top of the All Photos section; clearing await navigateToFolder(folderPath);
// the heap/section context mirrors mule-image's "drill into folder"
// behaviour. The URL sync $effect on the timeline picks this up.
setSection('all-photos');
setFolderPath(folderPath);
const params = new URLSearchParams();
params.set('folder', folderPath);
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
} }
function onCreateHeap() { function onCreateHeap() {
@@ -545,14 +422,12 @@
// //
// `getCount` is a getter (not a snapshot) so the badge reads the latest // `getCount` is a getter (not a snapshot) so the badge reads the latest
// derived value on every render — the arrays themselves are constant. // derived value on every render — the arrays themselves are constant.
// `count.all` already excludes archived/review/hidden (PhotoPrism's // Tags intentionally renders without a count badge; the count
// "everything visible in the main timeline" tally), so it matches what // columns inside the TagsBrowserSidebar are the canonical surface for
// the All photos view actually renders. Map uses the shared `['geo']` // per-tag totals. Review rolls in the duplicates tabs hosted under
// cache so its badge matches /map's "N geotagged" footer exactly — // /review — stacks always contributes; cross-folder only contributes
// `count.places` would have shown distinct locations instead. // once its tab has been opened (the scan is lazy, not eager from the
// Review rolls in the duplicates tabs hosted under /review — stacks // sidebar).
// always contributes; cross-folder only contributes once its tab has
// been opened (the scan is lazy, not eager from the sidebar).
type ViewItem = type ViewItem =
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined } | { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined }; | { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
@@ -562,42 +437,24 @@
// separate "everything regardless of folder" destination would just // separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root. // duplicate it for users whose photos live under the root.
const views: ViewItem[] = [ 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 // Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/ // `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat // Ratings/Countries) and a chevron, neither of which fits the flat
// section/route ViewItem shape. // section/route ViewItem shape. Notes lives under that expandable
// alongside the tag categories.
]; ];
// Total badge for the "Tags" header row. Rolls up labels + keywords + function isNotesActive(): boolean {
// ratings + colors. Labels flows through countPhotos (scoped); keywords/ return page.url.pathname === '/notes';
// ratings/colors are library-wide marks tables 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;
return labelsBadge + keywords + ratingsCount + colorsCount;
});
// 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[] = [ const manageViews: ViewItem[] = [
{ { kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
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 }
]; ];
function isRouteActive(href: string): boolean { function isRouteActive(href: string): boolean {
@@ -671,6 +528,17 @@
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library Library
</span> </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 <button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100" class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)} onclick={() => (settingsOpen = true)}
@@ -705,38 +573,31 @@
{#if hasSubfolders} {#if hasSubfolders}
<button <button
type="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} class:text-muted-foreground={!rootActive}
onclick={toggleRoot} onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'} title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'} aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
> >
{rootExpanded ? '▾' : '▸'} <ChevronRight
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
/>
</button> </button>
{:else} {:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned <!-- Spacer keeps chevronless rows aligned with their chevroned
peers, so labels share a common left edge across the sidebar. --> 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} {/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 <button
type="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('/')} onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`} title={rootTitle}
> >
<span class="truncate">{rootLabel}</span> <span class="truncate">{rootLabel}</span>
{#if configQuery.data} {#if rootSubLabel}
<span <span class="truncate text-muted-foreground" class:text-primary-foreground={rootActive}>
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive / {rootSubLabel}
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span> </span>
{/if} {/if}
</button> </button>
@@ -756,10 +617,12 @@
</KebabMenu> </KebabMenu>
</div> </div>
</div> </div>
{#if foldersQuery.isPending} {#if foldersQuery.isLoading}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p> <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} {:else if !hasSubfolders}
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p> <EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
{:else if rootExpanded} {:else if rootExpanded}
<!-- <!--
depth=1 visually nests the top-level subfolders one indent depth=1 visually nests the top-level subfolders one indent
@@ -774,7 +637,7 @@
onRename={onRenameFolder} onRename={onRenameFolder}
onDelete={onDeleteFolder} onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)} onCreateChild={(parent) => onCreateFolder(parent)}
counts={folderCounts} onMove={(path) => openMove({ kind: 'folder', path })}
/> />
{/if} {/if}
</div> </div>
@@ -795,11 +658,11 @@
</div> </div>
{#if heapsQuery.isPending} {#if heapsQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading heaps…" />
{:else if heapsQuery.isError} {:else if heapsQuery.isError}
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p> <EmptyState size="compact" tone="destructive" title="Failed to load heaps" />
{:else if (heapsQuery.data ?? []).length === 0} {:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p> <EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else} {:else}
<ul> <ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)} {#each heapsQuery.data ?? [] as heap (heap.UID)}
@@ -821,16 +684,9 @@
class="flex min-w-0 flex-1 items-center pl-6 text-left" class="flex min-w-0 flex-1 items-center pl-6 text-left"
onclick={() => navigateTo('heap', heap.UID)} onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)} ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`} title={heap.Title}
> >
<span class="truncate">{heap.Title}</span> <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> </button>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block"> <div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions"> <KebabMenu label="Heap actions">
@@ -857,7 +713,7 @@
</Item> </Item>
<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" 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" /> <FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder… Move to folder…
@@ -889,62 +745,34 @@
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} {#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)} {@render viewRow(v)}
{/each} {/each}
<!-- <!-- Notes -->
Tags expandable. Whole row is a toggle (chevron + label + badge); {#if true}
there is no landing page at /tags — selecting a sub-category is the {@const notesActive = isNotesActive()}
only way into a real view. <a
--> href="/notes"
<button class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
type="button" class:bg-primary={notesActive}
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent" class:text-primary-foreground={notesActive}
style="padding-left: 4px;" class:hover:bg-primary={notesActive}
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"
> >
{tagsExpanded ? '▾' : '▸'} <span class="truncate">Notes</span>
</span> </a>
<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}
{/if} {/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> </div>
<!-- Manage — curation flows that decide a photo's fate. Same <!-- Manage — curation flows that decide a photo's fate. Same
@@ -956,6 +784,33 @@
Manage Manage
</span> </span>
</div> </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}`)} {#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)} {@render viewRow(v)}
{/each} {/each}
@@ -999,6 +854,17 @@
> >
<Settings class="h-3.5 w-3.5" /> <Settings class="h-3.5 w-3.5" />
</button> </button>
{#if isAdminUser}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (usersOpen = true)}
title="Users"
aria-label="Manage users"
>
<Users class="h-3.5 w-3.5" />
</button>
{/if}
<button <button
type="button" type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1011,9 +877,11 @@
</footer> </footer>
</div> </div>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} /> <SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
<GeneralSettingsDialog <GeneralSettingsDialog
open={generalSettingsOpen} open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)} onClose={() => (generalSettingsOpen = false)}
/> />
{#if isAdminUser}
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
{/if}

View File

@@ -0,0 +1,619 @@
<!--
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.
UX model (Lightroom-style): tree is the primary surface, with a search
field on top that filters it live (matches + their ancestors, force-
expanded). Arrow keys rove through visible rows with selection following
focus; Enter confirms; recent destinations render as one-click chips.
Moves are undoable via ⌘Z / the toast's Undo action — the sidecar returns
per-file {from,to} pairs and /files/restore-moves plays them backwards.
-->
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { Dialog } from 'bits-ui';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, History, Loader2, Search } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
movePhotosToFolder,
moveFolder,
restoreMoves,
listFolders,
type PpFolder
} from '$lib/services/photoprism';
import { cachedPhoto } from '$lib/services/photoActions';
import { photoNameAndDir } from '$lib/types/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 { push as pushUndo } from '$lib/stores/undo.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);
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
let submitting = $state(false);
let filterText = $state('');
let searchEl = $state<HTMLInputElement | undefined>();
let contentEl = $state<HTMLElement | undefined>();
let recents = $state<string[]>([]);
// ── Recent destinations (Lightroom's "recent folders" affordance) ───
const RECENTS_KEY = $derived(`mule_move_recents:${userLibraryBase()}`);
function loadRecents(): string[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(RECENTS_KEY);
const arr = raw ? (JSON.parse(raw) as string[]) : [];
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveRecent(path: string) {
if (!browser) return;
const next = [path, ...recents.filter((p) => p !== path)].slice(0, 5);
recents = next;
try {
localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
} catch {
/* quota — recents are a nicety */
}
}
// Only offer recents that still exist (or the root sentinel '').
const liveRecents = $derived.by(() => {
const paths = new Set((foldersQuery.data ?? []).map((f) => f.Path));
return recents.filter((p) => p === '' || paths.has(p));
});
// ── Tree building: subject exclusion + search filter ─────────────────
// For folder reparent, exclude the folder itself and everything under it —
// you can't move a directory into its own subtree.
const basePaths = $derived.by(() => {
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
if (subject?.kind === 'folder') {
const self = subject.path;
return paths.filter((p) => p !== self && !p.startsWith(self + '/'));
}
return paths;
});
const filtering = $derived(filterText.trim().length > 0);
const folderTree = $derived.by(() => {
if (!filtering) return buildTree(basePaths);
// Keep matches plus every ancestor so the hit's branch renders whole;
// forceExpand on the tree makes the branch visible without touching
// the sidebar's persisted open/collapse state.
const q = filterText.trim().toLowerCase();
const keep = new Set<string>();
for (const p of basePaths) {
if (!p.toLowerCase().includes(q)) continue;
const parts = p.split('/');
for (let i = 1; i <= parts.length; i++) {
keep.add(parts.slice(0, i).join('/'));
}
}
// Intersect with basePaths so folder-subject exclusion survives.
return buildTree(basePaths.filter((p) => keep.has(p)));
});
const treeIsEmpty = $derived((foldersQuery.data ?? []).length === 0);
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 photoCount = $derived.by(() => {
if (subject?.kind === 'heap') return subject.heap.PhotoCount ?? 0;
if (subject?.kind === 'photos') return subject.uids.length;
return 0;
});
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 = photoCount;
return `${subject?.kind === 'heap' ? (subject.heap.Title ?? '') : ''} · ${n} photo${n === 1 ? '' : 's'}`;
}
if (subject?.kind === 'photos') {
return `${photoCount} photo${photoCount === 1 ? '' : 's'} selected`;
}
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
return '';
});
// ── Validation ───────────────────────────────────────────────────────
/** Mirrors the sidecar's sanitizeFilename rules so bad names are caught
* before the request instead of surfacing as a failed toast. */
const subfolderError = $derived.by(() => {
const t = subfolder.trim();
if (!t) return null;
if (t.length > 240) return 'Name is too long';
if (t.startsWith('.')) return "Can't start with a dot";
if (/[/\\\u0000]/.test(t)) return 'Slashes arent allowed — one level only';
return null;
});
// Pre-disable "Move" when every selected photo already sits in the target
// folder (only decidable when all photos are in the query cache — unknown
// photos fail open and the sidecar reports "already in target" per photo).
const allAlreadyInTarget = $derived.by(() => {
if (subject?.kind !== 'photos' || mode !== 'move') return false;
if (pickedPath === null || subfolder.trim()) return false;
const dest = toOriginalsPath(pickedPath);
let known = 0;
for (const uid of subject.uids) {
const p = cachedPhoto(uid);
if (!p) return false;
known++;
if (photoNameAndDir(p).path !== dest) return false;
}
return known > 0;
});
const canSubmit = $derived(
pickedPath !== null && !submitting && !subfolderError && !allAlreadyInTarget
);
const disabledReason = $derived.by(() => {
if (pickedPath === null) return 'Pick a destination folder first';
if (subfolderError) return subfolderError;
if (allAlreadyInTarget) return 'Everything is already in this folder';
return undefined;
});
const confirmLabel = $derived.by(() => {
if (kind === 'folder') return `Move “${folderName}”`;
const verb = mode === 'copy' ? 'Copy' : 'Move';
return `${verb} ${photoCount} photo${photoCount === 1 ? '' : 's'}`;
});
// Live destination preview under the tree.
const destPreview = $derived.by(() => {
if (pickedPath === null) return null;
const base = pickedPath === '' ? '/' : pickedPath;
const sub = !subfolderError && subfolder.trim() ? subfolder.trim() : '';
return sub ? (pickedPath === '' ? sub : `${base}/${sub}`) : base;
});
// Reset draft state whenever a new subject is picked (or the dialog closes
// and reopens), so the form is blank on every fresh open. Autofocus the
// search field once the portal has rendered.
$effect(() => {
void subject;
pickedPath = null;
mode = 'move';
subfolder = '';
deleteHeap = false;
submitting = false;
filterText = '';
if (subject !== null) {
recents = loadRecents();
void tick().then(() => searchEl?.focus());
}
});
// 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;
});
// ── Roving arrow-key focus: selection follows focus ─────────────────
function visibleRows(): HTMLElement[] {
if (!contentEl) return [];
return Array.from(contentEl.querySelectorAll<HTMLElement>('[data-move-row]'));
}
function onContentKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
// Enter confirms from anywhere in the dialog once a destination is
// picked — including the search and subfolder inputs. Row buttons
// also fire their own click (re-picking themselves) first, which
// is harmless.
const inSearch = e.target === searchEl;
if (canSubmit && !(inSearch && pickedPath === null)) {
e.preventDefault();
void submit();
}
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const rows = visibleRows();
if (rows.length === 0) return;
const active = document.activeElement as HTMLElement | null;
const idx = rows.findIndex((r) => r === active);
let next: HTMLElement | undefined;
if (idx === -1) {
// Entering the tree from the search box (or anywhere else).
next = e.key === 'ArrowDown' ? rows[0] : rows[rows.length - 1];
} else {
const ni = idx + (e.key === 'ArrowDown' ? 1 : -1);
if (ni < 0) {
// Off the top — hand focus back to the search field.
e.preventDefault();
searchEl?.focus();
return;
}
next = rows[Math.min(ni, rows.length - 1)];
}
if (next) {
e.preventDefault();
next.focus();
next.scrollIntoView({ block: 'nearest' });
// Selection follows focus (ARIA listbox convention) — arrowing
// through the tree is the same as clicking each row.
pickedPath = next.dataset.path ?? null;
}
}
function moveSummary(verb: string, count: number, errors: number): string {
const tail = errors > 0 ? ` · ${errors} skipped` : '';
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
}
/** Register an undo that plays the sidecar's moved pairs backwards, and
* attach it to the success toast. Runs at most once. */
function registerMoveUndo(
label: string,
moves: { from: string; to: string }[],
extraInvalidate?: () => void
): (() => void) | undefined {
if (moves.length === 0) return undefined;
let undone = false;
const undo = async () => {
if (undone) return;
undone = true;
try {
const res = await restoreMoves(moves);
if (res.errors.length > 0) {
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
description: res.errors[0].error
});
} else {
toast.success(`Moved back ${res.restored.length} file(s)`);
}
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
extraInvalidate?.();
} catch (err) {
undone = false; // network failure — files unmoved, allow retry
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(label, undo);
return () => void undo();
}
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 || !canSubmit) 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;
saveRecent(dest);
// 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'] });
const runUndo = registerMoveUndo(
`Moved heap “${s.heap.Title ?? ''}” (${r.moved} photos)${r.heap_deleted ? ' — heap itself not restored' : ''}`,
opMode === 'move' ? (r.movedFiles ?? []) : [],
() => qc.invalidateQueries({ queryKey: ['heaps'] })
);
toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
);
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'] });
const runUndo = registerMoveUndo(
`Moved ${r.moved} photo${r.moved === 1 ? '' : 's'}`,
opMode === 'move' ? (r.movedFiles ?? []) : []
);
toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
);
} 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.
const r = await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
const oldUiPath = s.path;
// Inverse of a folder move is another folder move, back under
// the old parent (both paths originals-relative from the
// response — independent of UI base-path prefixes).
let undone = false;
const undo = async () => {
if (undone) return;
undone = true;
try {
await moveFolder(
r.newPath,
r.oldPath.includes('/') ? r.oldPath.slice(0, r.oldPath.lastIndexOf('/')) : ''
);
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath === newUiPath) setFolderPath(oldUiPath);
toast.success(`Moved “${labelName}” back`);
} catch (err) {
undone = false;
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(`Moved folder “${labelName}”`, undo);
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, {
id: tid,
action: { label: 'Undo', onClick: () => void undo() }
});
// 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-3 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"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={contentEl} onkeydown={onContentKeydown} class="grid gap-3" aria-busy={submitting}>
<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>
<!-- Search over the tree — autofocused, filters live. -->
<div class="relative">
<Search
class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<input
bind:this={searchEl}
type="text"
placeholder="Search folders…"
aria-label="Search folders"
bind:value={filterText}
class="w-full rounded border border-input bg-background py-1.5 pl-7 pr-2 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<!-- Recent destinations — one-click chips. -->
{#if liveRecents.length > 0 && !filtering}
<div class="flex flex-wrap items-center gap-1" aria-label="Recent destinations">
<History class="h-3 w-3 text-muted-foreground" />
{#each liveRecents as r (r)}
<button
type="button"
class="max-w-[160px] truncate rounded-full border px-2 py-0.5 text-[10px] transition-colors
{pickedPath === r
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-secondary/60 text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => (pickedPath = r)}
title={r === '' ? '/' : r}
>
{r === '' ? '/' : r.split('/').pop()}
</button>
{/each}
</div>
{/if}
<!-- 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-[220px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if treeIsEmpty}
<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 = '')}
data-move-row=""
data-path=""
aria-pressed={pickedPath === ''}
>
/
</button>
{#if filtering && folderTree.length === 0}
<p class="px-2 py-2 text-[11px] text-muted-foreground">
No folders match “{filterText.trim()}”.
</p>
{:else}
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
forceExpand={filtering}
/>
{/if}
{/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]"
role="radiogroup"
aria-label="Move or copy"
>
<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"
aria-label="New subfolder name"
aria-invalid={Boolean(subfolderError)}
bind:value={subfolder}
class="rounded border bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring
{subfolderError ? 'border-destructive' : 'border-input'}"
/>
{#if subfolderError}
<span class="text-[11px] text-destructive" role="alert">{subfolderError}</span>
{/if}
</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}
<!-- Live destination preview -->
{#if destPreview !== null}
<p class="truncate text-[11px] text-muted-foreground" aria-live="polite">
{kind === 'folder' ? `Moving “${folderName}` : `${mode === 'copy' ? 'Copying' : 'Moving'} ${photoCount} photo${photoCount === 1 ? '' : 's'}`}
<span class="text-foreground/70">{destPreview}</span>
</p>
{/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={!canSubmit}
title={disabledReason}
>
{#if submitting}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{confirmLabel}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -9,21 +9,30 @@
import { Dialog, Tabs } from 'bits-ui'; import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { 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 { import {
cancelImport,
cancelIndex, cancelIndex,
getConfig,
getErrors, getErrors,
getSettings, getSettings,
getIndexSubpath,
listFoldersUnderBase,
saveSettings, saveSettings,
startImport, setIndexSubpath,
startIndex, startIndex,
type ImportBody,
type IndexBody, type IndexBody,
type PpFolder,
type PpLogEntry, type PpLogEntry,
type PpSettings type PpSettings
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { userBasePath } from '$lib/stores/session.svelte'; import type { PpClientConfig } from '$lib/types/photoprism';
import {
prefs,
setIndexSubpathState,
toOriginalsPath
} from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -33,7 +42,7 @@
const qc = useQueryClient(); const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library'); let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('library');
// ── Library tab ─────────────────────────────────────────────────────── // ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them // Pull settings only while the dialog is open so we don't keep them
@@ -45,22 +54,41 @@
enabled: open enabled: open
})); }));
/**
* Force the shape on every clone so each `bind:value={draft.index!.*}`
* etc. has a real object to write into. Older PhotoPrism versions
* return /settings without one or more of these sub-objects, and
* non-null assertions on a missing sub-object throw on the next tick
* when Svelte's bind getter reads through it.
*
* Same shape-coercion pattern used by GeneralSettingsDialog —
* keep them in sync if you add a new top-level group there.
*/
function normalize(s: PpSettings): PpSettings {
return {
...s,
index: s.index ?? {},
stack: s.stack ?? {},
download: s.download ?? {}
};
}
let draft = $state<PpSettings | null>(null); let draft = $state<PpSettings | null>(null);
// Re-clone on every open so reopening shows the freshest server state.
// Resetting on open (not close) avoids the race where bits-ui's exit
// animation keeps the form mounted with `draft === null` and the
// `bind:value={draft.download!.originals}` getter throws.
$effect(() => { $effect(() => {
if (settingsQuery.data && draft === null) { if (open && settingsQuery.data) {
draft = structuredClone(settingsQuery.data); draft = normalize(structuredClone(settingsQuery.data));
} }
}); });
// Reset the draft when the dialog closes so the next open re-reads.
$effect(() => {
if (!open) draft = null;
});
const saveMut = createMutation(() => ({ const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch), mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => { onSuccess: (next) => {
qc.setQueryData(['settings'], next); qc.setQueryData(['settings'], next);
draft = structuredClone(next); draft = normalize(structuredClone(next));
toast.success('Settings saved'); toast.success('Settings saved');
}, },
onError: (err) => onError: (err) =>
@@ -68,20 +96,71 @@
})); }));
function resetDraft() { function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data); 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 ───────────────────────────────────────────────────────── // ── Index tab ─────────────────────────────────────────────────────────
// Default the reindex path to the user's BasePath when scoping is on, // Default the reindex path to the effective library root (BasePath +
// so non-admins (and admins-with-BasePath) only rescan their own // chosen index sub-path), so a manual run only rescans the user's working
// subtree. PhotoPrism's /index expects originals-relative paths with // subtree. PhotoPrism's /index expects originals-relative paths with a
// a leading slash; `'/'` means the whole library. // leading slash; `'/'` means the whole library.
const _bp = userBasePath();
let indexForm = $state<IndexBody>({ let indexForm = $state<IndexBody>({
path: _bp === '' ? '/' : `/${_bp}`, path: '/' + toOriginalsPath('/'),
rescan: false, rescan: false,
cleanup: 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(() => ({ const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b), mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'), onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
@@ -95,21 +174,6 @@
toast.error(err instanceof Error ? err.message : 'Cancel failed') 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 ────────────────────────────────────────────────────────── // ── Logs tab ──────────────────────────────────────────────────────────
// Poll while the Logs tab is showing; pause otherwise so the dialog // Poll while the Logs tab is showing; pause otherwise so the dialog
// doesn't burn requests when the user is in another tab. // doesn't burn requests when the user is in another tab.
@@ -119,6 +183,64 @@
enabled: open && activeTab === 'logs', enabled: open && activeTab === 'logs',
refetchInterval: open && activeTab === 'logs' ? 5000 : false refetchInterval: open && activeTab === 'logs' ? 5000 : false
})); }));
// ── About tab ─────────────────────────────────────────────────────────
// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
// so the About tab never triggers an extra round-trip — config is
// already warm by the time the user opens this dialog.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: open && activeTab === 'about'
}));
// PhotoPrism's `flags` is a space-separated bag of feature toggles
// ("experimental tensorflow places webdav share download import oidc").
// Parse once so the chip grid can render in stable order.
const flagSet = $derived.by<Set<string>>(() => {
const raw = configQuery.data?.flags ?? '';
return new Set(raw.split(/\s+/).filter(Boolean));
});
// Env-driven knobs that don't have a runtime PP API. Listed here so the
// About tab can render a "you need to edit .env and restart" help
// section instead of pretending these are mutable from the UI.
interface EnvKnob {
envVar: string;
label: string;
on: boolean;
}
const envKnobs = $derived.by<EnvKnob[]>(() => {
const f = flagSet;
const oidc = configQuery.data?.ext?.oidc?.enabled === true;
return [
{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
];
});
// Show the config block collapsed by default — most users only want the
// version + counts; the env help is for the rare admin moment.
let envHelpOpen = $state(false);
// Library counts surfaced as a compact 2-column grid. Order matches
// what users care about most often (photos, then derived buckets).
const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
{ key: 'all', label: 'Photos' },
{ key: 'videos', label: 'Videos' },
{ key: 'live', label: 'Live photos' },
{ key: 'favorites', label: 'Favorites' },
{ key: 'review', label: 'In review' },
{ key: 'archived', label: 'Archived' },
{ key: 'hidden', label: 'Hidden' },
{ key: 'people', label: 'People' },
{ key: 'labels', label: 'Labels' },
{ key: 'folders', label: 'Folders' },
{ key: 'albums', label: 'Albums' }
];
</script> </script>
<Dialog.Root <Dialog.Root
@@ -141,7 +263,7 @@
Library settings Library settings
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground"> <Dialog.Description class="mt-1 text-xs text-muted-foreground">
Drive PhotoPrism's library, indexer, importer and server log. Drive the library, indexer, importer and server log.
</Dialog.Description> </Dialog.Description>
</div> </div>
<Dialog.Close <Dialog.Close
@@ -156,7 +278,7 @@
<Tabs.List <Tabs.List
class="mb-3 flex gap-1 border-b border-border" class="mb-3 flex gap-1 border-b border-border"
> >
{#each ['library', 'index', 'import', 'logs'] as const as t (t)} {#each ['library', 'index', 'logs', 'about'] as const as t (t)}
<Tabs.Trigger <Tabs.Trigger
value={t} 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" 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"
@@ -167,7 +289,73 @@
</Tabs.List> </Tabs.List>
<!-- Library — general settings --> <!-- 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} {#if settingsQuery.isPending}
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p> <p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
{:else if settingsQuery.isError} {:else if settingsQuery.isError}
@@ -203,25 +391,6 @@
</label> </label>
</section> </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"> <section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Stacks Stacks
@@ -272,8 +441,64 @@
/> />
Disable downloads entirely Disable downloads entirely
</label> </label>
{#if draft.download?.crc32 !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.crc32}
/>
Include CRC32 checksum
</label>
{/if}
{#if draft.download?.sha1 !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.sha1}
/>
Include SHA1 checksum
</label>
{/if}
</section> </section>
<!-- Indexer advanced — only renders the fields PP actually
reported. Older PP versions return a smaller `index`
block and we don't want to fabricate UI for missing keys. -->
{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Indexer advanced
</h3>
{#if draft.index?.skipMeta !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipMeta}
/>
Skip metadata-only changes
</label>
{/if}
{#if draft.index?.skipRaw !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipRaw}
/>
Skip RAW files
</label>
{/if}
{#if draft.index?.skipHidden !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipHidden}
/>
Skip hidden files
</label>
{/if}
</section>
{/if}
</div> </div>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
@@ -346,64 +571,136 @@
</div> </div>
</Tabs.Content> </Tabs.Content>
<!-- Import — manual import run --> <!-- About — version, library counts, env-driven config help -->
<Tabs.Content value="import" class="space-y-3 text-[12px] outline-none"> <Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
<p class="text-muted-foreground"> {#if configQuery.isPending}
Pulls files from the import folder into the library. With "move" <InlineLoader size="sm" label="Loading server info…" />
enabled, files are deleted from the import folder after a {:else if configQuery.isError || !configQuery.data}
successful import. <EmptyState
</p> size="compact"
<label class="flex flex-col gap-1"> tone="destructive"
<span class="text-muted-foreground">Source path</span> icon={AlertCircle}
<input title="Could not load server info"
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> {:else}
<label class="flex items-center gap-2"> {@const cfg = configQuery.data}
<input type="checkbox" bind:checked={importForm.move} /> <section class="space-y-1.5">
Move files (don't copy) after import <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
</label> Server
<label class="flex flex-col gap-1"> </h3>
<span class="text-muted-foreground">Destination subpath (optional)</span> <div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
<input <span class="text-muted-foreground">PhotoPrism</span>
type="text" <span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
bind:value={importForm.dest} <span class="text-muted-foreground">Site</span>
placeholder="e.g. 2026/05" <span class="truncate text-right" title={cfg.siteUrl}>
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring" {cfg.siteUrl || '—'}
/> </span>
</label> <span class="text-muted-foreground">Auth mode</span>
<div class="flex items-center justify-end gap-2 pt-1"> <span class="text-right">{cfg.mode}</span>
<button </div>
type="button" </section>
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
onclick={() => cancelImportMut.mutate()} <section class="space-y-1.5">
disabled={cancelImportMut.isPending || startImportMut.isPending} <h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
> Features
Cancel current </h3>
</button> <div class="flex flex-wrap gap-1.5">
<button {#each envKnobs as knob (knob.envVar)}
type="button" <span
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
onclick={() => startImportMut.mutate(importForm)} ? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
disabled={startImportMut.isPending} : 'border-border bg-secondary text-muted-foreground'}"
> title={knob.envVar}
{#if startImportMut.isPending} >
<Loader2 class="h-3 w-3 animate-spin" /> <span
class="h-1.5 w-1.5 rounded-full {knob.on
? 'bg-green-500'
: 'bg-muted-foreground/40'}"
></span>
{knob.label}
</span>
{/each}
</div>
</section>
{#if cfg.count}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
{#each COUNT_ROWS as row (row.key)}
{@const v = cfg.count?.[row.key]}
{#if v !== undefined}
<span class="text-muted-foreground">{row.label}</span>
<span class="text-right tabular-nums">{v}</span>
{/if}
{/each}
</div>
</section>
{/if}
<!-- Env-driven config: there is no PhotoPrism API for these.
The panel surfaces what's on/off and reminds the admin
where to flip the switch — .env + restart. -->
<section class="space-y-2">
<button
type="button"
class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
onclick={() => (envHelpOpen = !envHelpOpen)}
aria-expanded={envHelpOpen}
>
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server configuration (env-driven)
</span>
<span class="text-[10px] text-muted-foreground">
{envHelpOpen ? '▾' : '▸'}
</span>
</button>
{#if envHelpOpen}
<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
<p class="text-muted-foreground">
These knobs aren't exposed through the API. Edit
<code class="rounded bg-background px-1">.env</code>
on the host and restart PhotoPrism:
</p>
<pre
class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
>docker compose up -d photoprism
# or, with podman-compose:
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
<ul class="space-y-0.5">
{#each envKnobs as knob (knob.envVar)}
<li>
<code class="rounded bg-background px-1">{knob.envVar}</code>
<span class:text-green-600={knob.on}
class:text-muted-foreground={!knob.on}>
{knob.on ? 'enabled' : 'disabled'}
</span>
</li>
{/each}
</ul>
{#if cfg.ext?.oidc?.enabled}
<p class="text-muted-foreground">
OIDC provider:
<span class="text-foreground">
{cfg.ext.oidc.provider ?? '—'}
</span>
</p>
{/if}
</div>
{/if} {/if}
Start import </section>
</button> {/if}
</div>
</Tabs.Content> </Tabs.Content>
<!-- Logs — recent server errors --> <!-- Logs — recent server errors -->
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none"> <Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Most recent PhotoPrism errors and warnings. Auto-refreshes Most recent server errors and warnings. Auto-refreshes every
every 5 seconds. 5 seconds.
</p> </p>
<button <button
type="button" type="button"
@@ -418,11 +715,16 @@
</button> </button>
</div> </div>
{#if errorsQuery.isPending} {#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading error log…" />
{:else if errorsQuery.isError} {:else if errorsQuery.isError}
<p class="px-1 text-destructive">Could not load error log.</p> <EmptyState
size="compact"
tone="destructive"
icon={AlertCircle}
title="Could not load error log"
/>
{:else if (errorsQuery.data ?? []).length === 0} {:else if (errorsQuery.data ?? []).length === 0}
<p class="px-1 text-muted-foreground">No errors logged.</p> <EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
{:else} {:else}
<ul <ul
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]" class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"

View File

@@ -0,0 +1,131 @@
<!--
Keyboard-shortcut reference overlay, opened with `?` (and the toolbar
help affordance). Read-only: gridKeyNav swallows every key except
Esc / ? while it's up, so nothing here can fire the actions it lists.
-->
<script lang="ts">
import { closeShortcuts, view } from '$lib/stores/view.svelte';
import { X } from 'lucide-svelte';
interface Row {
keys: string[];
desc: string;
}
interface Group {
title: string;
rows: Row[];
}
const GROUPS: Group[] = [
{
title: 'Navigate',
rows: [
{ keys: ['↑', '↓', '←', '→'], desc: 'Move focus in the grid' },
{ keys: ['Shift', '+', 'Arrows'], desc: 'Extend selection' },
{ keys: ['Space'], desc: 'Open / close preview' },
{ keys: ['Esc'], desc: 'Collapse selection, then clear' },
{ keys: ['/'], desc: 'Focus search' },
{ keys: ['⌘', 'A'], desc: 'Select all visible' },
{ keys: ['⌘', 'K'], desc: 'Command palette' }
]
},
{
title: 'Rate & label',
rows: [
{ keys: ['1', '…', '5'], desc: 'Set rating (re-key to clear)' },
{ keys: ['0'], desc: 'Clear rating' },
{ keys: ['6', '7', '8', '9'], desc: 'Color label: red / yellow / green / blue' },
{ keys: ['F'], desc: 'Toggle favorite' }
]
},
{
title: 'Act',
rows: [
{ keys: ['X'], desc: 'Archive (Delete in Archive view)' },
{ keys: ['U'], desc: 'Restore from archive' },
{ keys: ['S'], desc: 'Keep (review) · add to heap' },
{ keys: ['S', 'then', '19'], desc: 'Add to heap N' },
{ keys: ['A'], desc: 'Accept date & keep (EXIF review)' },
{ keys: ['M'], desc: 'Move to folder' },
{ keys: ['⌘', 'Z'], desc: 'Undo last action' }
]
},
{
title: 'Panels',
rows: [
{ keys: ['B'], desc: 'Toggle left sidebar' },
{ keys: ['Tab'], desc: 'Toggle left sidebar' },
{ keys: ['I'], desc: 'Toggle info sidebar' },
{ keys: ['?'], desc: 'This overlay' }
]
},
{
title: 'Stacks & Duplicates',
rows: [
{ keys: ['↑', '↓', 'j', 'k'], desc: 'Move between groups' },
{ keys: ['←', '→'], desc: 'Pick which file/copy to keep' },
{ keys: ['1', '…', '9'], desc: 'Jump straight to a file/copy' },
{ keys: ['Space'], desc: 'Compare candidates fullscreen (stacks)' },
{ keys: ['Enter'], desc: 'Resolve: keep selected, quarantine rest' },
{ keys: ['⌘', 'Z'], desc: 'Undo — restores quarantined files' }
]
}
];
</script>
{#if view.shortcutsOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-sm"
onclick={(e) => {
if (e.target === e.currentTarget) closeShortcuts();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
class="max-h-[85vh] w-[min(680px,92vw)] overflow-y-auto rounded-lg border border-border bg-popover p-5 text-popover-foreground shadow-xl"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
<button
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={closeShortcuts}
aria-label="Close"
>
<X class="h-4 w-4" />
</button>
</div>
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
{#each GROUPS as group (group.title)}
<section>
<h3 class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{group.title}
</h3>
<dl class="space-y-1">
{#each group.rows as row (row.desc)}
<div class="flex items-center justify-between gap-3 text-xs">
<dt class="text-muted-foreground">{row.desc}</dt>
<dd class="flex shrink-0 items-center gap-0.5">
{#each row.keys as k (k)}
{#if k === 'then' || k === '+' || k === '…'}
<span class="px-0.5 text-[10px] text-muted-foreground">{k}</span>
{:else}
<kbd
class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none"
>
{k}
</kbd>
{/if}
{/each}
</dd>
</div>
{/each}
</dl>
</section>
{/each}
</div>
</div>
</div>
{/if}

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

@@ -0,0 +1,484 @@
<!--
Admin-only user management. PhotoPrism exposes /api/v1/users CRUD; this
dialog wraps it in a list/edit two-pane so the PP web UI never has to be
opened for routine user changes. Mounted from LeftSidebar's footer
(visible only when session.user.Role === 'admin').
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, Plus, Trash2, Users as UsersIcon, X } from 'lucide-svelte';
import {
createUser,
deleteUser,
listUsers,
setUserPassword,
updateUser,
type CreateUserBody
} from '$lib/services/photoprism';
import type { PpRole, PpUser } from '$lib/types/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
const usersQuery = createQuery<PpUser[]>(() => ({
queryKey: ['users'],
queryFn: listUsers,
enabled: open
}));
// Selection state: a UID picks an existing user from the list; `null`
// means "no selection" (right pane empty); `'new'` opens the new-user
// form. Reset whenever the dialog opens so reopening doesn't strand a
// stale form.
type Selection = string | 'new' | null;
let selection = $state<Selection>(null);
$effect(() => {
if (open) selection = null;
});
const ROLES: PpRole[] = ['admin', 'user', 'contributor', 'guest', 'visitor'];
// Editable copy of the selected user. Re-cloned on every selection
// change so the form starts from the server-side snapshot (and a
// failed save doesn't leak stale values into the next selection).
let draft = $state<EditableUser>(emptyDraft());
interface EditableUser {
UID: string;
Name: string;
DisplayName: string;
Email: string;
Role: PpRole;
BasePath: string;
UploadPath: string;
WebDAV: boolean;
Password: string;
}
function emptyDraft(): EditableUser {
return {
UID: '',
Name: '',
DisplayName: '',
Email: '',
Role: 'user',
BasePath: '',
UploadPath: '',
WebDAV: false,
Password: ''
};
}
function userToDraft(u: PpUser): EditableUser {
return {
UID: u.UID,
Name: u.Name ?? '',
DisplayName: u.DisplayName ?? '',
Email: u.Email ?? '',
Role: u.Role ?? 'user',
BasePath: u.BasePath ?? '',
UploadPath: u.UploadPath ?? '',
// Server may or may not return WebDAV depending on PP version;
// default to false rather than guessing the current value.
WebDAV: Boolean((u as PpUser & { WebDAV?: boolean }).WebDAV),
Password: ''
};
}
$effect(() => {
if (selection === 'new') {
draft = emptyDraft();
} else if (selection) {
const u = (usersQuery.data ?? []).find((x) => x.UID === selection);
if (u) draft = userToDraft(u);
} else {
draft = emptyDraft();
}
});
// Password sub-form (only relevant when editing an existing user).
// Decoupled from `draft` because the password endpoint is a separate
// PUT and never goes through createUser/updateUser.
let pwNew = $state('');
let pwConfirm = $state('');
$effect(() => {
// Reset password fields whenever the selection changes.
void selection;
pwNew = '';
pwConfirm = '';
});
function toBody(d: EditableUser): CreateUserBody {
const body: CreateUserBody = {
Name: d.Name.trim(),
Role: d.Role
};
if (d.DisplayName.trim()) body.DisplayName = d.DisplayName.trim();
if (d.Email.trim()) body.Email = d.Email.trim();
if (d.BasePath.trim()) body.BasePath = d.BasePath.trim();
if (d.UploadPath.trim()) body.UploadPath = d.UploadPath.trim();
body.WebDAV = d.WebDAV;
return body;
}
const createMut = createMutation(() => ({
mutationFn: async () => {
if (!draft.Name.trim()) throw new Error('Username is required');
if (!draft.Password || draft.Password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
const body = toBody(draft);
body.Password = draft.Password;
return createUser(body);
},
onSuccess: (u) => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success(`Created user ${u.Name}`);
selection = u.UID;
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create user')
}));
const updateMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
return updateUser(selection, toBody(draft));
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success('User updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update user')
}));
const deleteMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
return deleteUser(selection);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success('User deleted');
selection = null;
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not delete user')
}));
const pwMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
if (pwNew.length < 8) throw new Error('Password must be at least 8 characters');
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
// Admin-issued password reset: PhotoPrism accepts an empty `old`
// when the caller is an admin acting on another user.
await setUserPassword(selection, '', pwNew);
},
onSuccess: () => {
pwNew = '';
pwConfirm = '';
toast.success('Password updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update password')
}));
function onDeleteClick() {
if (!draft.Name) return;
if (!confirm(`Delete user "${draft.Name}"? This cannot be undone.`)) return;
deleteMut.mutate();
}
const isSelf = $derived(
selection !== 'new' && selection !== null && selection === session.user?.UID
);
const inputClass =
'rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[760px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<UsersIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">Users</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Manage accounts. Roles + per-user library paths come from the
server's ACL — changes apply immediately.
</Dialog.Description>
</div>
<Dialog.Close
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</Dialog.Close>
</div>
<div class="grid grid-cols-[220px_1fr] gap-4">
<!-- Left pane: user list + new-user trigger. -->
<div class="flex max-h-[480px] flex-col overflow-hidden rounded border border-border">
<button
type="button"
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-border px-2 text-left text-[12px] hover:bg-accent"
class:bg-accent={selection === 'new'}
onclick={() => (selection = 'new')}
>
<Plus class="h-3.5 w-3.5" />
<span>New user</span>
</button>
<div class="flex-1 overflow-y-auto">
{#if usersQuery.isPending}
<p class="px-2 py-2 text-[12px] text-muted-foreground">Loading…</p>
{:else if usersQuery.isError}
<p class="px-2 py-2 text-[12px] text-destructive">
Could not load users.
</p>
{:else}
<ul>
{#each usersQuery.data ?? [] as u (u.UID)}
{@const active = selection === u.UID}
<button
type="button"
class="flex w-full flex-col gap-0.5 border-b border-border/40 px-2 py-1.5 text-left text-[12px] hover:bg-accent"
class:bg-accent={active}
onclick={() => (selection = u.UID)}
>
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium">
{u.DisplayName?.trim() || u.Name}
</span>
<span
class="shrink-0 rounded bg-secondary px-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
{u.Role}
</span>
</span>
{#if u.BasePath}
<span class="truncate text-[11px] text-muted-foreground">
{u.BasePath}
</span>
{/if}
</button>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Right pane: edit form for selected user (or empty/new form). -->
<div class="min-w-0">
{#if selection === null}
<div
class="flex h-full min-h-[280px] items-center justify-center rounded border border-dashed border-border p-4 text-center text-[12px] text-muted-foreground"
>
Pick a user on the left, or click "New user" to create one.
</div>
{:else}
<form
class="space-y-3"
onsubmit={(e) => {
e.preventDefault();
if (selection === 'new') createMut.mutate();
else updateMut.mutate();
}}
>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Username
<span class="text-destructive">*</span>
</span>
<input
type="text"
bind:value={draft.Name}
required
autocomplete="off"
disabled={selection !== 'new'}
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Display name</span>
<input
type="text"
bind:value={draft.DisplayName}
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Email</span>
<input
type="email"
bind:value={draft.Email}
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Role</span>
<select bind:value={draft.Role} class={inputClass}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Base path
</span>
<input
type="text"
bind:value={draft.BasePath}
placeholder="e.g. alice"
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Upload path
</span>
<input
type="text"
bind:value={draft.UploadPath}
autocomplete="off"
class={inputClass}
/>
</label>
</div>
<label class="flex items-center gap-2 text-[12px]">
<input type="checkbox" bind:checked={draft.WebDAV} />
Allow WebDAV access
</label>
{#if selection === 'new'}
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Initial password <span class="text-destructive">*</span>
</span>
<input
type="password"
bind:value={draft.Password}
required
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
{/if}
<div class="flex items-center justify-between gap-2 border-t border-border pt-3">
{#if selection !== 'new'}
<button
type="button"
class="flex items-center gap-1.5 rounded border border-destructive/40 px-3 py-1 text-[12px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
onclick={onDeleteClick}
disabled={isSelf || deleteMut.isPending}
title={isSelf ? 'Cannot delete yourself' : 'Delete user'}
>
{#if deleteMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{:else}
<Trash2 class="h-3 w-3" />
{/if}
Delete
</button>
{:else}
<span></span>
{/if}
<button
type="submit"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={createMut.isPending || updateMut.isPending || !draft.Name.trim()}
>
{#if createMut.isPending || updateMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{selection === 'new' ? 'Create user' : 'Save changes'}
</button>
</div>
</form>
{#if selection !== 'new'}
<!-- Admin-issued password reset. Separate from the user's own
password change in GeneralSettingsDialog (which requires
their current password); admins reset without old-pw. -->
<form
class="mt-4 space-y-3 rounded border border-border bg-muted/30 p-3"
onsubmit={(e) => {
e.preventDefault();
pwMut.mutate();
}}
>
<h4 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Reset password
</h4>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">New password</span>
<input
type="password"
bind:value={pwNew}
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Confirm</span>
<input
type="password"
bind:value={pwConfirm}
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
class="flex items-center gap-1.5 rounded border border-border px-3 py-1 text-[12px] hover:bg-accent disabled:opacity-50"
disabled={pwMut.isPending || pwNew.length < 8 || pwNew !== pwConfirm}
>
{#if pwMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Set password
</button>
</div>
</form>
{/if}
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -0,0 +1,112 @@
<!--
"Name new faces" — the missing half of the People feature. PhotoPrism
only creates a person once someone names a detected face cluster, so a
library can have tens of thousands of face markers and still show an
empty People list. This panel surfaces the caller's unnamed clusters
(scoped server-side to their BasePath) as face-crop cards with an
inline name input; naming goes through PhotoPrism's own flow (PUT on
the cluster's representative marker), which creates the Subject and
propagates it across the cluster.
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
listUnnamedFaces,
nameFaceCluster,
type UnnamedFaceCluster
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { InlineLoader } from '$lib/components/feedback';
import { UserPlus } from 'lucide-svelte';
const qc = useQueryClient();
const facesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
queryKey: ['faces', 'unnamed'],
queryFn: listUnnamedFaces,
enabled: isAuthenticated(),
staleTime: 60_000
}));
let drafts = $state<Record<string, string>>({});
let busy = $state<Record<string, boolean>>({});
async function submit(cluster: UnnamedFaceCluster) {
const name = (drafts[cluster.faceId] ?? '').trim();
if (!name || busy[cluster.faceId]) return;
busy[cluster.faceId] = true;
try {
await nameFaceCluster(cluster.markerUid, name);
toast.success(`Named ${name}`, {
description:
'PhotoPrism links the whole cluster in the background — the photo count may keep growing.'
});
drafts[cluster.faceId] = '';
void qc.invalidateQueries({ queryKey: ['faces', 'unnamed'] });
void qc.invalidateQueries({ queryKey: ['subjects'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Naming failed');
} finally {
busy[cluster.faceId] = false;
}
}
const clusters = $derived(facesQuery.data ?? []);
</script>
{#if facesQuery.isPending}
<InlineLoader label="Looking for unnamed faces…" />
{:else if clusters.length > 0}
<section class="space-y-3">
<header class="space-y-0.5">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-foreground">
<UserPlus class="h-4 w-4" /> Name new faces
</h2>
<p class="text-[11px] text-muted-foreground">
Faces PhotoPrism detected but nobody has named yet. Naming one creates a person and tags
every matching photo.
</p>
</header>
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));"
>
{#each clusters as cluster (cluster.faceId)}
<div
class="flex flex-col items-center gap-2 rounded-md border border-border bg-card/30 p-3"
>
<div class="relative">
<img
src={thumbUrl(cluster.thumb, 'tile_224')}
alt="Unnamed face"
loading="lazy"
decoding="async"
class="h-20 w-20 rounded-full border border-border object-cover"
/>
<span
class="absolute -bottom-1 -right-1 rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground"
title={`${cluster.count} of your photos carry this face`}
>
{cluster.count}
</span>
</div>
<input
type="text"
placeholder="Name…"
disabled={busy[cluster.faceId]}
class="w-full rounded border border-input bg-background px-1.5 py-1 text-center text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={drafts[cluster.faceId]}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit(cluster);
}
}}
onblur={() => void submit(cluster)}
/>
</div>
{/each}
</div>
</section>
{/if}

View File

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

View File

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

View File

@@ -9,12 +9,16 @@
throw away. Until the timer fires, the poster image stands in. throw away. Until the timer fires, the poster image stands in.
--> -->
<script lang="ts"> <script lang="ts">
import { createQuery } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism'; 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 { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
interface Props { interface Props {
uid: string | null; uid: string | null;
@@ -25,6 +29,8 @@
} }
let { uid, order, showChevrons = true }: Props = $props(); let { uid, order, showChevrons = true }: Props = $props();
const qc = useQueryClient();
const photoQuery = createQuery<PpPhoto>(() => ({ const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''], queryKey: ['photo', uid ?? ''],
queryFn: () => getPhoto(uid as string), queryFn: () => getPhoto(uid as string),
@@ -33,6 +39,48 @@
const currentIndex = $derived(uid ? order.indexOf(uid) : -1); 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; const VIDEO_LOAD_DELAY_MS = 250;
let armedUid = $state<string | null>(null); let armedUid = $state<string | null>(null);
@@ -54,15 +102,23 @@
setFocused(next); setFocused(next);
setAnchor(next); setAnchor(next);
} }
// ── Zoom & pan ───────────────────────────────────────────────────────
// Gesture handling lives in the shared zoomPan action (also used by
// the duplicates compare lightbox). Transform lives on a wrapper so
// the LQIP layer and the sharp image scale together. Resets on photo
// change via resetKey. Past 1.25× the sharp <img> switches to
// fit_2048 so zoomed pixels stay crisp.
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
</script> </script>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4"> <div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if uid === null} {#if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p> <EmptyState icon={ImageIcon} title="Select a photo to preview" />
{:else if photoQuery.isPending} {:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p> <InlineLoader label="Loading photo…" align="center" />
{:else if photoQuery.isError} {:else if photoQuery.isError}
<p class="text-sm text-destructive">Failed to load photo.</p> <EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
{:else if photoQuery.data} {:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)} {@const pf = primaryFile(photoQuery.data)}
{#if showChevrons && currentIndex > 0} {#if showChevrons && currentIndex > 0}
@@ -84,29 +140,68 @@
</button> </button>
{/if} {/if}
{#if isVideo(photoQuery.data)} {#if isVideo(photoQuery.data) && armedUid === uid}
{@const vf = videoFile(photoQuery.data)} {@const vf = videoFile(photoQuery.data)}
{#if armedUid === uid} {#key vf.Hash}
{#key vf.Hash} <VideoPlayer
<VideoPlayer src={videoUrl(vf.Hash)}
src={videoUrl(vf.Hash)} poster={thumbUrl(pf.Hash, 'fit_1280')}
poster={thumbUrl(pf.Hash, 'fit_1280')} title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
/>
{/key}
{:else}
<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"
/> />
{/if} {/key}
{:else} {:else}
<img {@const altText =
src={thumbUrl(pf.Hash, 'fit_1280')} photoQuery.data.OriginalName ??
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'} pf.Name ??
class="max-h-full max-w-full rounded-md object-contain shadow-2xl" (isVideo(photoQuery.data) ? 'Video' : 'Photo')}
/> <div
use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zp.zoom > 1
? zp.panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="relative flex h-full w-full items-center justify-center"
class:transition-transform={!zp.panning}
class:duration-150={!zp.panning}
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
>
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={thumbSrc(pf.Hash, view.thumbnailSize)}
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/>
{/if}
<img
src={thumbUrl(pf.Hash, zp.zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
draggable="false"
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
/>
</div>
{#if zp.zoom > 1}
<span
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
>
{Math.round(zp.zoom * 100)}% · double-click to reset
</span>
{/if}
</div>
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@@ -1,246 +1,61 @@
<!-- <!--
One review-queue cause group rendered as a card. Mirrors StackGroupCard's One review-queue cause group, rendered as a thin wrapper around
chrome (focusable container, ResizeObserver column tracking, keyboard PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
nav) but the per-tile semantics differ: 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 The Low Resolution tab opts in to PhotoTile's dimension badge so the
sidebar. Shift-click toggles bulk-select instead of opening. user can spot under-2-MP photos without opening each tile.
- 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.
--> -->
<script lang="ts"> <script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import { batchArchive } from '$lib/services/photoprism';
approvePhoto, import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
batchArchive import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
} from '$lib/services/photoprism'; import { type ReviewGroup } from '$lib/services/adapters/review';
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';
interface Props { interface Props {
group: ReviewGroup; 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(); 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); 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() { async function archiveAll() {
if (busy || group.photos.length === 0) return; if (busy || group.photos.length === 0) return;
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`)) if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
return; return;
const uids = group.photos.map((p) => p.UID);
const tid = toast.loading(`Archiving ${uids.length}…`);
busy = true; busy = true;
startBulk(`Archiving…`, uids);
try { try {
await batchArchive(group.photos.map((p) => p.UID)); await batchArchive(uids);
toast.success(`Archived ${group.photos.length}`); doneBulk(`Archived ${uids.length}`, uids);
toast.success(`Archived ${uids.length}`, { id: tid });
void qc.invalidateQueries({ queryKey: ['review-groups'] }); void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });
void qc.invalidateQueries({ queryKey: ['marks'] });
} catch (err) { } 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 { } finally {
busy = false; 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() { function runSuggestion() {
if (group.meta.suggestedAction === 'approve') void approveAll(); // Only 'archive' suggestions are reachable through this button now —
else if (group.meta.suggestedAction === 'archive') void archiveAll(); // the page's BulkActionBar handles per-photo / multi-select Keep.
// 'manual' suggestion has no button — the suggestion line is text-only. if (group.meta.suggestedAction === 'archive') void archiveAll();
} }
</script> </script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="space-y-2">
<!-- 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"
>
<header class="flex items-center justify-between gap-3"> <header class="flex items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<div class="text-sm font-medium text-foreground"> <div class="text-sm font-medium text-foreground">
@@ -248,128 +63,30 @@
<span class="ml-1 text-muted-foreground">({group.photos.length})</span> <span class="ml-1 text-muted-foreground">({group.photos.length})</span>
</div> </div>
</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> </header>
<!-- Suggestion line — sits above the grid, surfaces the likely-correct <!-- Suggestion line — surfaces the likely-correct bulk action. Only
bulk action with an inline trigger. 'manual' causes get no 'archive' renders a quick-button; 'manual' is text-only and
inline button; the user has to use the header bulk bar instead. --> 'approve' is unused today. Per-photo Keep / Archive comes from the
page's BulkActionBar (review section) once the user selects. -->
<div <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" 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> <span>{group.meta.suggestion}</span>
{#if group.meta.suggestedAction !== 'manual'} {#if group.meta.suggestedAction === 'archive'}
<button <button
type="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" 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} disabled={busy}
onclick={runSuggestion} onclick={runSuggestion}
> >
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'} Archive all
</button> </button>
{/if} {/if}
</div> </div>
<div <PhotoGrid
bind:this={gridEl} photos={group.photos}
class="grid gap-2" dimensionBadge={group.cause === 'low_resolution'}
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>
</div> </div>

View File

@@ -17,7 +17,9 @@
type PhotoMarksMap, type PhotoMarksMap,
type UpdatePhotoBody type UpdatePhotoBody
} from '$lib/services/photoprism'; } 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(); const qc = useQueryClient();
@@ -34,10 +36,19 @@
let colorDraft = $state<string | null>(null); let colorDraft = $state<string | null>(null);
let busy = $state(false); 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; busy = true;
if (label) startBulk(`${label}…`, ids);
try { try {
return await fn(); const result = await fn();
if (label) doneBulk(label, ids);
return result;
} catch (e) {
if (label) failBulk(ids);
throw e;
} finally { } finally {
busy = false; busy = false;
} }
@@ -46,13 +57,16 @@
async function applyNote() { async function applyNote() {
if (busy) return; if (busy) return;
const value = noteDraft; const value = noteDraft;
await withBusy(() => const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
patchTargets( await withBusy(
ids, () =>
{ Caption: value, CaptionSrc: 'manual' }, patchTargets(
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`, ids,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' }) { Caption: value, CaptionSrc: 'manual' },
) label,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
),
label
); );
noteDraft = ''; noteDraft = '';
} }
@@ -63,27 +77,30 @@
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite // Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
// fill the local timezone field downstream. // fill the local timezone field downstream.
const iso = `${dateDraft}T00:00:00Z`; const iso = `${dateDraft}T00:00:00Z`;
await withBusy(() => const label = `Date → ${ids.length}`;
patchTargets( await withBusy(
ids, () =>
buildTakenAtPatch(iso), patchTargets(
`Date → ${ids.length}`, ids,
(p) => // Per-photo patch so each photo keeps its own UTC↔local
p.TakenAt // offset when the date is stamped across a selection.
? buildTakenAtPatch(p.TakenAt) (p) => buildTakenAtPatch(iso, p),
: ({ TakenSrc: '' } as UpdatePhotoBody) label,
) (p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt, p)
: ({ TakenSrc: '' } as UpdatePhotoBody)
),
label
); );
dateDraft = ''; dateDraft = '';
} }
async function applyMarks(patch: PhotoMark, label: string) { async function applyMarks(patch: PhotoMark, label: string) {
if (busy) return; if (busy) return;
const tid = toast.loading(`${label}…`);
startBulk(`${label}…`, ids);
await withBusy(async () => { 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) => { qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) }; const map = { ...(prev ?? {}) };
for (const id of ids) { for (const id of ids) {
@@ -97,9 +114,14 @@
}); });
try { try {
await bulkSetMarks(ids, patch); 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) { } 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'] }); void qc.invalidateQueries({ queryKey: ['marks'] });
} }
}); });
@@ -119,35 +141,31 @@
colorDraft = null; 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() { async function applyKeyword() {
if (busy) return; if (busy) return;
const kw = keywordDraft.trim().replace(/,/g, ''); const kw = keywordDraft.trim().replace(/,/g, '');
if (!kw) return; if (!kw) return;
keywordDraft = ''; keywordDraft = '';
await withBusy(() => const label = `Tagged "${kw}" → ${ids.length}`;
patchTargets( await withBusy(
ids, () =>
(p) => { patchTargets(
const cur = (p.Details?.Keywords ?? '') ids,
.split(',') (p) => {
.map((k) => k.trim()) const cur = (p.Details?.Keywords ?? '')
.filter(Boolean); .split(',')
if (cur.includes(kw)) return {}; .map((k) => k.trim())
const next = [...cur, kw].join(', '); .filter(Boolean);
return { Details: { Keywords: next, KeywordsSrc: 'manual' } }; if (cur.includes(kw)) return {};
}, const next = [...cur, kw].join(', ');
`Tagged "${kw}" → ${ids.length}`, return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
(p) => ({ },
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' } label,
}) (p) => ({
) Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
),
label
); );
} }
@@ -270,16 +288,18 @@
</button> </button>
</section> </section>
<!-- Color label — same pattern as Score. --> <!-- Colors — same pattern as Score. -->
<section class="space-y-1"> <section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div> <div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label"> <div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)} {#each COLOR_SWATCHES as c (c.key)}
{@const picked = colorDraft === c.key}
<button <button
type="button" type="button"
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}" class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
class:ring-foreground={colorDraft === c.key} ? c.bg
class:ring-transparent={colorDraft !== c.key} : 'bg-transparent'}"
aria-pressed={picked}
disabled={busy} disabled={busy}
onclick={() => (colorDraft = c.key)} onclick={() => (colorDraft = c.key)}
title={`Pick ${c.title}`} title={`Pick ${c.title}`}

View File

@@ -1,100 +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';
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="text-[10px] text-muted-foreground/70">Loading {title.toLowerCase()}</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,21 +6,26 @@
PUT (Details fields need the full body). PUT (Details fields need the full body).
--> -->
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import {
Aperture, Aperture,
ArrowUpRight,
Calendar, Calendar,
ExternalLink, Copy,
File, File,
Folder, Folder,
Globe,
HardDrive, HardDrive,
Heart,
ImageIcon, ImageIcon,
Loader2, Loader2,
MapPin, MapPin,
Star, Star,
Tag, Tag,
Timer, Timer,
User,
X X
} from 'lucide-svelte'; } from 'lucide-svelte';
import { import {
@@ -34,31 +39,46 @@
type PhotoMarksMap, type PhotoMarksMap,
type UpdatePhotoBody type UpdatePhotoBody
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { invalidateFacets } from '$lib/services/bulk';
import { toggleFavorite } from '$lib/services/photoActions';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import {
import RelatedStrip from './RelatedStrip.svelte'; isVideo,
photoNameAndDir,
primaryFile,
videoFile,
type PpPhoto
} from '$lib/types/photoprism';
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
import { goto } from '$app/navigation';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props { 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; photo: PpPhoto;
} }
let { photo, showRelated = false }: Props = $props(); let { photo }: Props = $props();
const qc = useQueryClient(); const qc = useQueryClient();
let basename = $state(''); let basename = $state('');
let title = $state('');
let caption = $state(''); let caption = $state('');
let takenAt = $state(''); let takenAt = $state('');
let lat = $state(''); let lat = $state('');
let lng = $state(''); let lng = $state('');
let altitude = $state('');
let country = $state(''); let country = $state('');
let keywords = $state<string[]>([]); let keywords = $state<string[]>([]);
let keywordDraft = $state(''); let keywordDraft = $state('');
let renaming = $state(false); let renaming = $state(false);
let artist = $state('');
let copyright = $state('');
let license = $state('');
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory /** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
* prefix and basename. Sidecar's rename endpoint only accepts a bare * prefix and basename. Sidecar's rename endpoint only accepts a bare
@@ -72,16 +92,21 @@
$effect(() => { $effect(() => {
const pf = primaryFile(photo); const pf = primaryFile(photo);
basename = splitName(pf.Name ?? '').base; basename = splitName(pf.Name ?? '').base;
title = photo.Title ?? '';
caption = photo.Caption ?? ''; caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 10); takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : ''; lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : ''; lng = photo.Lng ? String(photo.Lng) : '';
altitude = photo.Altitude ? String(photo.Altitude) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : ''; country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {}; const det = photo.Details ?? {};
keywords = (det.Keywords ?? '') keywords = (det.Keywords ?? '')
.split(',') .split(',')
.map((k) => k.trim()) .map((k) => k.trim())
.filter(Boolean); .filter(Boolean);
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
}); });
const patchMutation = createMutation(() => ({ const patchMutation = createMutation(() => ({
@@ -89,12 +114,20 @@
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo; const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
return updatePhoto(fresh, patch); return updatePhoto(fresh, patch);
}, },
onMutate: () => {
startBulk('Saving…', [photo.UID]);
},
onSuccess: (data) => { onSuccess: (data) => {
qc.setQueryData(['photo', data.UID], data); qc.setQueryData(['photo', data.UID], data);
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });
// Keep the keyword / notes facet panels in sync with the edit.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
}, },
onError: (err) => onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Save failed') failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
})); }));
function commit(patch: UpdatePhotoBody) { function commit(patch: UpdatePhotoBody) {
@@ -129,7 +162,38 @@
if (caption === (photo.Caption ?? '')) return; if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' }); commit({ Caption: caption, CaptionSrc: 'manual' });
} }
function commitTitle() {
const next = title.trim();
if (next === (photo.Title ?? '')) return;
commit({ Title: next, TitleSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt)); 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() { function commitTakenAt() {
if (!takenAt) return; if (!takenAt) return;
if (!isValidISODate(takenAt)) { if (!isValidISODate(takenAt)) {
@@ -143,14 +207,16 @@
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z'; const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`; const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return; if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso)); commit(buildTakenAtPatch(iso, photo));
} }
function commitGps() { function commitGps() {
const nlat = parseFloat(lat); const nlat = parseFloat(lat);
const nlng = parseFloat(lng); const nlng = parseFloat(lng);
const nalt = parseFloat(altitude);
const patch: UpdatePhotoBody = {}; const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat; if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng; if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt;
if (Object.keys(patch).length) commit(patch); if (Object.keys(patch).length) commit(patch);
} }
function commitCountry() { function commitCountry() {
@@ -160,7 +226,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' }); commit({ Country: next || 'zz', CountrySrc: 'manual' });
} }
type DetailsKey = 'Keywords'; type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
function commitDetails(field: DetailsKey, value: string) { function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? ''; const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return; if (value === prev) return;
@@ -206,12 +272,17 @@
if (!optimistic.rating) delete optimistic.rating; if (!optimistic.rating) delete optimistic.rating;
if (!optimistic.color) delete optimistic.color; if (!optimistic.color) delete optimistic.color;
patchMarksCache(photo.UID, optimistic); patchMarksCache(photo.UID, optimistic);
startBulk('Saving…', [photo.UID]);
try { try {
const saved = await setMark(photo.UID, patch); const saved = await setMark(photo.UID, patch);
patchMarksCache(photo.UID, saved); patchMarksCache(photo.UID, saved);
// Refresh the Colors / Ratings facet panels off the sidecar truth.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
} catch (err) { } catch (err) {
// Rollback on failure. // Rollback on failure.
patchMarksCache(photo.UID, prev); patchMarksCache(photo.UID, prev);
failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed'); toast.error(err instanceof Error ? err.message : 'Save failed');
} }
} }
@@ -225,29 +296,55 @@
} }
/** Click-to-toggle: clicking the current color clears it; clicking a /** 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) { function setColor(next: string) {
const value = currentColor === next ? '' : next; const value = currentColor === next ? '' : next;
if (value === currentColor) return; if (value === currentColor) return;
void applyMark({ color: value }); 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 photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
const currentRating = $derived(photoMark.rating ?? 0); const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? ''); const currentColor = $derived(photoMark.color ?? '');
// Named face markers across all file variants, deduped by subject.
// Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped,
// non-alphanumerics collapsed to '-') so the person link resolves the
// same drill URL the sidebar list uses.
function personSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const peopleChips = $derived.by(() => {
const seen = new Map<string, { subjUid: string; name: string; slug: string }>();
for (const f of photo.Files ?? []) {
for (const m of f.Markers ?? []) {
if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue;
seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) });
}
}
return [...seen.values()];
});
const pf = $derived(primaryFile(photo)); const pf = $derived(primaryFile(photo));
// Video facts come from the video variant (primary is often the JPEG
// poster for Live Photos / transcoded clips).
const vf = $derived(isVideo(photo) ? videoFile(photo) : null);
const durationStr = $derived.by(() => {
// PpFile.Duration is Go time.Duration → nanoseconds.
const ns = vf?.Duration ?? 0;
if (ns <= 0) return '';
const totalSec = Math.round(ns / 1_000_000_000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
});
const dirPath = $derived(splitName(pf.Name ?? '').dir); const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—'); const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived( const sizeStr = $derived(
pf.Size pf.Size
@@ -266,12 +363,6 @@
? photo.Country.toUpperCase() ? 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 { function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
if (!c) return ''; if (!c) return '';
const make = c.Make ?? ''; const make = c.Make ?? '';
@@ -279,6 +370,36 @@
const joined = `${make} ${model}`.trim(); const joined = `${make} ${model}`.trim();
return joined && joined !== 'Unknown' ? joined : ''; return joined && joined !== 'Unknown' ? joined : '';
} }
/** Quote for PhotoPrism's q= DSL — mirrors filters.svelte's quoteIfNeeded,
* duplicated here since that helper isn't exported. */
function quoteTerm(v: string): string {
return /^[A-Za-z0-9_-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`;
}
/** Jump to the timeline filtered by a raw DSL term (camera:/lens:) — the
* q-DSL escape hatch from the toolbar search box, triggered by click
* instead of typing. */
async function jumpToSearch(term: string): Promise<void> {
setSection('all-photos');
setSearch(term);
await goto('/', { keepFocus: true, noScroll: true });
}
async function copyExif(): Promise<void> {
const lines = [
cameraStr && `Camera: ${cameraStr}`,
lensStr && lensStr !== cameraStr && `Lens: ${lensStr}`,
exposureParts.fnum && `Aperture: ${exposureParts.fnum}`,
exposureParts.exp && `Shutter: ${exposureParts.exp}`,
exposureParts.iso && exposureParts.iso,
exposureParts.focal && `Focal length: ${exposureParts.focal}`,
photo.TakenAt && `Taken: ${photo.TakenAt}`
].filter(Boolean);
if (lines.length === 0) {
toast.message('No EXIF to copy');
return;
}
await navigator.clipboard.writeText(lines.join('\n'));
toast.success('EXIF copied');
}
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } { function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
return { return {
iso: p.Iso ? `ISO ${p.Iso}` : '', iso: p.Iso ? `ISO ${p.Iso}` : '',
@@ -333,19 +454,61 @@
/> />
</div> </div>
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input <!-- Date suggestion derived from filename / folder signals. Only
padding on filename / date so the read-only text starts at the shown on the EXIF Stripped review tab; amber styling marks
same x-offset as the editable rows above — otherwise spans it as unconfirmed. `(estimated day)` hint appears when the
hug the icon while inputs sit 4px in. --> day was synthesised because only Y-M was available — same
{#if dirPath} row, just so the user knows that part is fabricated. Apply
<div class="flex items-center gap-2"> writes the value into the date input above and commits as
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> a manual TakenAt edit. -->
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}> {#if showDateSuggestion && dateSuggestion}
{dirPath}/ <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> </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> </div>
{/if} {/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 --> <!-- Dimensions -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -362,45 +525,35 @@
</span> </span>
</div> </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"> <div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <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"> <span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'} {placeLabel || 'No location'}
</span> </span>
{#if mapsHref} {#if photo.Country && photo.Country !== 'zz'}
<a <button
href={mapsHref} type="button"
target="_blank"
rel="noopener"
class="text-muted-foreground hover:text-foreground" 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" /> <Globe class="h-3 w-3" />
</a> </button>
{/if} {/if}
</div> </div>
</dl> </dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match <!-- Tags — note, score, color label, keywords, and auto-labels grouped
mule-image's nomenclature). --> under one collapsible section. Note (PhotoPrism's Caption field,
<div class="space-y-1"> labelled here to match mule-image's nomenclature) sits at the top
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div> of the group since it's the most-edited per-photo field. Score +
<textarea color are stored on the mule-sidecar (PhotoPrism's PUT can't
rows="2" persist them); keywords live on Details; auto-labels come from
placeholder="Add a note…" PhotoPrism's TF classifier and are read-only. Open by default
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" since these are the culling marks the user reaches for first. -->
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. -->
<details <details
class="rounded border border-border" class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)} open={getMetadataSectionOpen('tags', true)}
@@ -414,6 +567,28 @@
</span> </span>
</summary> </summary>
<div class="space-y-2 p-2 pt-1"> <div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
<input
type="text"
placeholder="Add a title…"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={title}
onblur={commitTitle}
/>
</div>
<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="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div> <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"> <div class="flex items-center gap-0.5" role="group" aria-label="Rating">
@@ -430,18 +605,33 @@
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} /> <Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button> </button>
{/each} {/each}
<!-- PhotoPrism's native favorite — syncs to mobile gallery apps. -->
<button
type="button"
class="ml-2 p-0.5 transition-colors {photo.Favorite
? 'text-red-500'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => void toggleFavorite([photo.UID])}
title={photo.Favorite ? 'Remove from favorites (f)' : 'Add to favorites (f)'}
aria-pressed={photo.Favorite ?? false}
aria-label="Favorite"
>
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
</button>
</div> </div>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div> <div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label"> <div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)} {#each COLOR_SWATCHES as c (c.key)}
{@const picked = currentColor === c.key}
<button <button
type="button" type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}" class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
class:ring-foreground={currentColor === c.key} ? c.bg
class:ring-transparent={currentColor !== c.key} : 'bg-transparent'}"
aria-pressed={picked}
onclick={() => setColor(c.key)} onclick={() => setColor(c.key)}
title={c.title} title={c.title}
aria-label={`Color ${c.key}`} aria-label={`Color ${c.key}`}
@@ -484,6 +674,27 @@
</div> </div>
</div> </div>
<!-- Recognized people — named face markers on this photo's files.
Read-only chips linking to the person's page. -->
{#if peopleChips.length > 0}
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
<div class="flex flex-wrap gap-1">
{#each peopleChips as person (person.subjUid)}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
onclick={() => void navigateToTag('people', person.slug)}
title={`View photos of ${person.name}`}
>
<User class="h-2.5 w-2.5 text-muted-foreground" />
{person.name}
</button>
{/each}
</div>
</div>
{/if}
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read- <!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
only: editing labels requires re-indexing on PhotoPrism's only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them side. The dashed border + lower contrast distinguishes them
@@ -511,35 +722,6 @@
</div> </div>
</details> </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 <!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store. choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr Avoid data-driven defaults here — they make the `open` attr
@@ -587,6 +769,62 @@
onblur={commitCountry} onblur={commitCountry}
/> />
</label> </label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
<input
type="number"
step="1"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={altitude}
onblur={commitGps}
/>
</label>
</div>
</details>
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
Closed by default; persists once opened. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('credits', false)}
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Credits
</summary>
<div class="space-y-1.5 p-2 pt-1">
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Artist</span>
<input
type="text"
placeholder="Photographer…"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={artist}
onblur={() => commitDetails('Artist', artist.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Copyright</span>
<input
type="text"
placeholder="© …"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={copyright}
onblur={() => commitDetails('Copyright', copyright.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">License</span>
<input
type="text"
placeholder="e.g. CC BY-NC 4.0"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={license}
onblur={() => commitDetails('License', license.trim())}
/>
</label>
</div> </div>
</details> </details>
@@ -597,20 +835,51 @@
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)} ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
> >
<summary <summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground" class="flex cursor-pointer items-center justify-between px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
> >
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<ImageIcon class="h-3 w-3" /> File <ImageIcon class="h-3 w-3" /> File
</span> </span>
<button
type="button"
class="normal-case text-muted-foreground hover:text-foreground"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
void copyExif();
}}
title="Copy EXIF summary"
aria-label="Copy EXIF summary"
>
<Copy class="h-3 w-3" />
</button>
</summary> </summary>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]"> <dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
{#if cameraStr} {#if cameraStr}
<dt class="text-muted-foreground">Camera</dt> <dt class="text-muted-foreground">Camera</dt>
<dd class="text-foreground/80">{cameraStr}</dd> <dd class="min-w-0">
<button
type="button"
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
onclick={() => void jumpToSearch(`camera:${quoteTerm(cameraStr)}`)}
title={`View other photos taken with ${cameraStr}`}
>
{cameraStr}
</button>
</dd>
{/if} {/if}
{#if lensStr && lensStr !== cameraStr} {#if lensStr && lensStr !== cameraStr}
<dt class="text-muted-foreground">Lens</dt> <dt class="text-muted-foreground">Lens</dt>
<dd class="text-foreground/80">{lensStr}</dd> <dd class="min-w-0">
<button
type="button"
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
onclick={() => void jumpToSearch(`lens:${quoteTerm(lensStr)}`)}
title={`View other photos taken with ${lensStr}`}
>
{lensStr}
</button>
</dd>
{/if} {/if}
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal} {#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
<dt class="text-muted-foreground">Exposure</dt> <dt class="text-muted-foreground">Exposure</dt>
@@ -631,6 +900,18 @@
{/if} {/if}
<dt class="text-muted-foreground">Type</dt> <dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd> <dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
{#if durationStr}
<dt class="text-muted-foreground">Duration</dt>
<dd class="text-foreground/80">{durationStr}</dd>
{/if}
{#if vf?.FPS}
<dt class="text-muted-foreground">FPS</dt>
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
{/if}
{#if vf?.Codec}
<dt class="text-muted-foreground">Codec</dt>
<dd class="text-foreground/80">{vf.Codec}</dd>
{/if}
<dt class="text-muted-foreground">Hash</dt> <dt class="text-muted-foreground">Hash</dt>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd> <dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt> <dt class="text-muted-foreground">Indexed</dt>

View File

@@ -1,13 +1,21 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { import {
aggregateKeywords, aggregateKeywords,
getAllMarks, getAllMarks,
listCountries,
listLabels, listLabels,
listPhotos, listPhotosByUids,
listSubjects,
listUnnamedFaces,
type AggregatedKeyword, type AggregatedKeyword,
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel type PpCountry,
type PpLabel,
type PpSubject,
type UnnamedFaceCluster
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from '$lib/actions/nearBottom';
@@ -18,7 +26,10 @@
COLOR_SWATCHES, COLOR_SWATCHES,
starLabel starLabel
} from '$lib/utils/tagGroups'; } from '$lib/utils/tagGroups';
import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Globe, Hash, Tag, User, UserPlus } from 'lucide-svelte';
interface Props { interface Props {
category: TagCategory; category: TagCategory;
@@ -27,6 +38,29 @@
} }
const { category, selectedValue, onSelect }: Props = $props(); const { category, selectedValue, onSelect }: Props = $props();
// "Name new faces" is a pinned row, not a subject — it needs to stay
// reachable even after every detected face has been named once (there's
// always another to catch as the library grows), so it lives outside
// the value-drives-URL selection model the rest of this sidebar uses.
// Query key matches NewFacesPanel's so the two share one cache entry.
const unnamedFacesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
queryKey: ['faces', 'unnamed'],
queryFn: listUnnamedFaces,
enabled: isAuthenticated() && category === 'people',
staleTime: 60_000
}));
const unnamedFacesCount = $derived(unnamedFacesQuery.data?.length ?? 0);
const newFacesActive = $derived(page.url.searchParams.get('view') === 'new-faces');
function showNewFaces() {
// Deliberately NOT onSelect/navigateToTag — that drives the
// `[[value]]` route param, and the auto-select-first-tag effect
// below immediately overwrites a null value with the first real
// person, which is exactly the trap this row exists to escape.
// The `view` query param is independent state the page reads to
// show the naming panel instead of (or alongside) the photo grid.
void goto('/tags/people?view=new-faces', { keepFocus: true, noScroll: true });
}
let filterText = $state(''); let filterText = $state('');
// Reset the inline filter input whenever the user switches categories so // Reset the inline filter input whenever the user switches categories so
@@ -54,6 +88,18 @@
staleTime: 5 * 60_000 staleTime: 5 * 60_000
})); }));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const countriesQuery = createQuery<PpCountry[]>(() => ({
queryKey: ['countries'],
queryFn: listCountries,
enabled: isAuthenticated() && category === 'countries'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({ const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'], queryKey: ['marks'],
queryFn: getAllMarks, queryFn: getAllMarks,
@@ -62,12 +108,17 @@
})); }));
// Same marks-pool query the drill page uses — colors/ratings need a // Same marks-pool query the drill page uses — colors/ratings need a
// representative photo per bucket for the count rollup. Cheap once // representative photo per bucket for the count rollup. Resolved from the
// cached; the drill page kicks the same key. // 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[]>(() => ({ const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'], queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }), queryFn: () => listPhotosByUids(markedUids),
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors') enabled:
isAuthenticated() &&
(category === 'ratings' || category === 'colors') &&
markedUids.length > 0
})); }));
// PhotoPrism returns labels in arbitrary order; sort by photo count // PhotoPrism returns labels in arbitrary order; sort by photo count
@@ -96,6 +147,33 @@
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q)); return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
}); });
const subjectsSorted = $derived(
[...(subjectsQuery.data ?? [])].sort(
(a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)
)
);
const filteredSubjects = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return subjectsSorted;
return subjectsSorted.filter(
(s) =>
s.Name.toLowerCase().includes(q) || s.Slug.toLowerCase().includes(q)
);
});
// 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( const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data) buildRatingGroups(marksQuery.data, marksPoolQuery.data)
); );
@@ -122,8 +200,12 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount)); const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.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 hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length); const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
function loadMore() { function loadMore() {
visibleCount += PAGE_SIZE; visibleCount += PAGE_SIZE;
@@ -138,6 +220,9 @@
function pickKeyword(value: string) { function pickKeyword(value: string) {
if (selectedValue !== value) onSelect(value); if (selectedValue !== value) onSelect(value);
} }
function pickPerson(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickColor(key: string) { function pickColor(key: string) {
if (selectedValue !== key) onSelect(key); if (selectedValue !== key) onSelect(key);
} }
@@ -145,6 +230,9 @@
const s = String(r); const s = String(r);
if (selectedValue !== s) onSelect(s); 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 // First non-empty entry for the active category. Labels/keywords are
// already sorted by count desc, so [0] is the most-used tag; colors // already sorted by count desc, so [0] is the most-used tag; colors
@@ -159,6 +247,9 @@
if (category === 'keywords') { if (category === 'keywords') {
return keywordsSorted[0]?.keyword ?? null; return keywordsSorted[0]?.keyword ?? null;
} }
if (category === 'people') {
return subjectsSorted[0]?.Slug ?? null;
}
if (category === 'colors') { if (category === 'colors') {
return colorGroups[0]?.key ?? null; return colorGroups[0]?.key ?? null;
} }
@@ -166,6 +257,9 @@
const g = ratingGroups[0]; const g = ratingGroups[0];
return g ? String(g.rating) : null; return g ? String(g.rating) : null;
} }
if (category === 'countries') {
return countriesSorted[0]?.Code ?? null;
}
return null; return null;
}); });
@@ -176,8 +270,16 @@
// fires when there's genuinely no selection — once a value is picked // fires when there's genuinely no selection — once a value is picked
// (by the user or by this effect), the URL drives selectedValue and // (by the user or by this effect), the URL drives selectedValue and
// the effect no-ops. // the effect no-ops.
//
// `newFacesActive` additionally suppresses it for People: navigating
// to the pinned "Name new faces" row necessarily clears selectedValue
// (it targets a bare `/tags/people` URL), and without this guard this
// effect would immediately redirect straight back to the first named
// person in the same tick — permanently hiding the naming workflow
// again the moment a second person exists to auto-select into.
$effect(() => { $effect(() => {
if (selectedValue != null) return; if (selectedValue != null) return;
if (newFacesActive) return;
if (firstValue == null) return; if (firstValue == null) return;
onSelect(firstValue, { replace: true }); onSelect(firstValue, { replace: true });
}); });
@@ -187,12 +289,21 @@
? 'Labels' ? 'Labels'
: category === 'keywords' : category === 'keywords'
? 'Keywords' ? 'Keywords'
: category === 'colors' : category === 'people'
? 'Colors' ? 'People'
: 'Ratings' : category === 'colors'
? 'Colors'
: category === 'countries'
? 'Countries'
: 'Ratings'
); );
const showFilterInput = $derived(category === 'labels' || category === 'keywords'); const showFilterInput = $derived(
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
</script> </script>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
@@ -214,13 +325,15 @@
{#if category === 'labels'} {#if category === 'labels'}
{#if labelsQuery.isPending} {#if labelsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading labels…</p> <InlineLoader size="sm" label="Loading labels…" />
{:else if labelsQuery.isError} {:else if labelsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load labels.</p> <EmptyState size="compact" tone="destructive" title="Failed to load labels" />
{:else if filteredLabels.length === 0} {:else if filteredLabels.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <EmptyState
{filterText ? 'No labels match the filter.' : 'No labels yet.'} size="compact"
</p> icon={Tag}
title={filterText ? 'No labels match the filter' : 'No labels yet'}
/>
{:else} {:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto"> <div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleLabels as label (label.UID ?? label.Slug)} {#each visibleLabels as label (label.UID ?? label.Slug)}
@@ -276,17 +389,21 @@
{/if} {/if}
{:else if category === 'keywords'} {:else if category === 'keywords'}
{#if keywordsQuery.isPending} {#if keywordsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <InlineLoader
Loading keywords… (aggregates from photo details — first load may take a few seconds) size="sm"
</p> label="Loading keywords… (aggregates from photo details — first load may take a few seconds)"
/>
{:else if keywordsQuery.isError} {:else if keywordsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load keywords.</p> <EmptyState size="compact" tone="destructive" title="Failed to load keywords" />
{:else if filteredKeywords.length === 0} {:else if filteredKeywords.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <EmptyState
{filterText size="compact"
? 'No keywords match the filter.' icon={Hash}
: 'No user-set keywords yet. Add them from a photos right-sidebar metadata panel.'} title={filterText ? 'No keywords match the filter' : 'No user-set keywords yet'}
</p> description={filterText
? undefined
: 'Add them from a photos right-sidebar metadata panel.'}
/>
{:else} {:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto"> <div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleKeywords as kw (kw.keyword)} {#each visibleKeywords as kw (kw.keyword)}
@@ -333,6 +450,167 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{:else if category === 'people'}
<!-- Pinned above the named-people list (and shown regardless of its
loading/empty/error state) so naming stays reachable even after
every currently-detected face has a name — new faces keep
appearing as the library grows. -->
<button
type="button"
class="flex h-8 w-full shrink-0 items-center gap-2 border-b border-border px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={newFacesActive}
class:text-primary-foreground={newFacesActive}
class:hover:bg-primary={newFacesActive}
onclick={showNewFaces}
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full {newFacesActive
? 'bg-primary-foreground/15'
: 'bg-secondary'}"
>
<UserPlus class="h-3 w-3 {newFacesActive ? '' : 'text-muted-foreground'}" />
</span>
<span class="min-w-0 flex-1 truncate font-medium">Name new faces</span>
{#if unnamedFacesCount > 0}
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {newFacesActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{unnamedFacesCount}
</span>
{/if}
</button>
{#if subjectsQuery.isPending}
<InlineLoader size="sm" label="Loading people…" />
{:else if subjectsQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load people" />
{:else if filteredSubjects.length === 0}
<EmptyState
size="compact"
icon={User}
title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText
? undefined
: 'A person appears here once you name a detected face — use the "Name new faces" cards on the right.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleSubjects as subject (subject.UID ?? subject.Slug)}
{@const active = subject.Slug === 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={() => pickPerson(subject.Slug)}
title={subject.Name}
>
{#if subject.Thumb}
<img
src={thumbUrl(subject.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-secondary"
>
<User class="h-3 w-3 text-muted-foreground" />
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{subject.Name}</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'}"
>
{subject.PhotoCount ?? 0}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreSubjects,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreSubjects}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredSubjects.length})
</p>
{/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'} {:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending} {#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p> <p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
@@ -355,7 +633,8 @@
onclick={() => pickColor(swatch.key)} onclick={() => pickColor(swatch.key)}
title={swatch.title} 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="min-w-0 flex-1 truncate">{swatch.title}</span>
<span <span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active 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"> <script lang="ts">
import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import {
@@ -12,6 +13,9 @@
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch'; 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 { import {
clearBulkToFirst, clearBulkToFirst,
clearSelection, clearSelection,
@@ -22,6 +26,17 @@
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.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';
const qc = useQueryClient(); const qc = useQueryClient();
let busy = $state(false); let busy = $state(false);
@@ -48,11 +63,50 @@
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0 selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
); );
const isBulk = $derived(selection.ids.size > 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) — // Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with // every other action is hidden so the choice can't be confused with
// heap-adding / restoring. The S keybinding is rerouted to approve // heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason. // from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review'); 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 // Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed // to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived; // from "archive" to "delete" since the photo is already archived;
@@ -68,38 +122,87 @@
setFocused(null); 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; busy = true;
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
try { 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 { } finally {
busy = false; busy = false;
void qc.invalidateQueries({ queryKey: ['photos'] }); 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() { async function onApprove() {
const ids = snapshotIds(); const ids = snapshotIds();
if (ids.length === 0) return; if (ids.length === 0) return;
const tid = toast.loading(`Keeping ${ids.length}…`);
await withBusy(async () => { await withBusy(async () => {
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
// no /unapprove route. We fan out per-photo because there's no onProgress: (_done, _total, completedId) => {
// batch endpoint either. Errors are tallied rather than aborting const p = cachedPhoto(completedId);
// the loop so a single bad UID doesn't block the rest. setDetail(p?.FileName ?? completedId);
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id)); }
});
if (errors.length) { if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`); toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
} else { } 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); focusAfter(ids);
clearSelection(); 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() { async function onArchive() {
const ids = snapshotIds(); const ids = snapshotIds();
if (ids.length === 0) return; if (ids.length === 0) return;
const tid = toast.loading(`Archiving ${ids.length}…`);
await withBusy(async () => { await withBusy(async () => {
try { try {
await batchArchive(ids); await batchArchive(ids);
@@ -107,16 +210,13 @@
await batchRestore(ids); await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] }); 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); focusAfter(ids);
clearSelection(); clearSelection();
toast.success(`Archived ${ids.length}`); toast.success(`Archived ${ids.length}`, { id: tid });
} catch (err) { } 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() { async function onDelete() {
@@ -127,61 +227,67 @@
? 'Permanently delete this photo? This cannot be undone.' ? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`; : `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return; if (!confirm(msg)) return;
const tid = toast.loading(`Deleting ${ids.length}…`);
await withBusy(async () => { await withBusy(async () => {
try { try {
await batchDelete(ids); await batchDelete(ids);
focusAfter(ids); focusAfter(ids);
clearSelection(); clearSelection();
toast.success(`Deleted ${ids.length}`); toast.success(`Deleted ${ids.length}`, { id: tid });
} catch (err) { } 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() { async function onRestore() {
const ids = snapshotIds(); const ids = snapshotIds();
if (ids.length === 0) return; if (ids.length === 0) return;
const tid = toast.loading(`Restoring ${ids.length}…`);
await withBusy(async () => { await withBusy(async () => {
try { try {
await batchRestore(ids); await batchRestore(ids);
markRemoved(ids);
pushUndo(`Restored ${ids.length}`, async () => { pushUndo(`Restored ${ids.length}`, async () => {
await batchArchive(ids); await batchArchive(ids);
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });
}); });
focusAfter(ids); focusAfter(ids);
clearSelection(); clearSelection();
toast.success(`Restored ${ids.length}`); toast.success(`Restored ${ids.length}`, { id: tid });
} catch (err) { } 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) { async function onAddToHeap(heap: PpAlbum) {
const ids = snapshotIds(); const ids = snapshotIds();
if (!ids.length) return; if (!ids.length) return;
heapPickerOpen = false; heapPickerOpen = false;
const tid = toast.loading(`Adding ${ids.length}${heap.Title}…`);
startBulk(`Adding to ${heap.Title}…`, ids);
await withBusy(async () => { await withBusy(async () => {
try { try {
const { added } = await addToHeap(heap.UID, ids); const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] }); 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) { if (added.length === 0) {
failBulk(ids);
toast.error(`Nothing added to ${heap.Title}`, { toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).` id: tid,
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
}); });
return; return;
} }
doneBulk(`Added ${added.length}${heap.Title}`, ids);
await delay(400);
if (added.length < ids.length) { if (added.length < ids.length) {
toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, { toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, {
id: tid,
description: 'The rest were already in this heap.' description: 'The rest were already in this heap.'
}); });
} else { } 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 () => { pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added); await removeFromHeap(heap.UID, added);
@@ -189,7 +295,8 @@
}); });
clearSelection(); clearSelection();
} catch (err) { } 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 });
} }
}); });
} }
@@ -205,13 +312,19 @@
<div <div
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1" 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 {targetCount} selected
{:else} </span>
Focused photo {:else}
{/if} <span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
</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 `overflow-x-auto` would clip the heap-picker dropdown — CSS
@@ -229,16 +342,31 @@
the archive section. Everything else (heap, restore) the archive section. Everything else (heap, restore)
is hidden so the choice reads as decisive. --> is hidden so the choice reads as decisive. -->
<button <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} disabled={busy}
onclick={onApprove} onclick={onApprove}
title="Keep — accept into timeline" title="Keep — accept into timeline"
> >
✓ Keep ✓ 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>
{#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 <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} disabled={busy}
onclick={onArchive} onclick={onArchive}
title="Archive" title="Archive"
@@ -253,13 +381,13 @@
photo is already archived; the destructive styling photo is already archived; the destructive styling
reinforces the irreversibility. --> reinforces the irreversibility. -->
<button <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} disabled={busy}
onclick={onRestore} onclick={onRestore}
title="Keep — restore to timeline" title="Keep — restore to timeline"
> >
✓ Keep ✓ 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>
<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" 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"
@@ -273,13 +401,13 @@
{:else} {:else}
<div class="relative"> <div class="relative">
<button <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} disabled={busy}
onclick={() => (heapPickerOpen = !heapPickerOpen)} onclick={() => (heapPickerOpen = !heapPickerOpen)}
title="Add to heap (S then 19 picks a heap)" title="Add to heap (S then 19 picks a heap)"
> >
Add to 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 >S&nbsp;N</kbd
> >
</button> </button>
@@ -288,9 +416,9 @@
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg" class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
> >
{#if heapsQuery.isPending} {#if heapsQuery.isPending}
<p class="px-2 py-1 text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading heaps…" />
{:else if (heapsQuery.data ?? []).length === 0} {:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p> <EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else} {:else}
{#each heapsQuery.data ?? [] as heap, i (heap.UID)} {#each heapsQuery.data ?? [] as heap, i (heap.UID)}
<button <button
@@ -318,7 +446,7 @@
{/if} {/if}
</div> </div>
<button <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} disabled={busy}
onclick={onArchive} onclick={onArchive}
title="Archive" title="Archive"
@@ -326,9 +454,18 @@
Archive Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd> <kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button> </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} {/if}
<button <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} onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'} 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 * `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */ * preset the timeline uses. */
columns?: string; 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( const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))` columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
); );
@@ -91,6 +95,7 @@
<PhotoTile <PhotoTile
{photo} {photo}
selected={sel} selected={sel}
{dimensionBadge}
onClick={(e) => onClick(e, photo.UID)} onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)} onDblclick={(e) => onDblclick(e, photo.UID)}
/> />

View File

@@ -16,17 +16,33 @@
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte"; import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
import { view } from "$lib/stores/view.svelte"; import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism"; import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
import { toggleFavorite } from "$lib/services/photoActions";
import { fade } from "svelte/transition";
import { Loader2, Check, Heart, X } from "lucide-svelte";
interface Props { interface Props {
photo: PpPhoto; photo: PpPhoto;
selected: boolean; selected: boolean;
onClick: (e: MouseEvent) => void; onClick: (e: MouseEvent) => void;
onDblclick: (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 hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo)); 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 // 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 // video when you hover the tile in the grid. We wait HOVER_DELAY ms
@@ -39,7 +55,7 @@
let hoverTimer: ReturnType<typeof setTimeout> | null = null; let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function onMouseEnter() { function onMouseEnter() {
if (!video || selected) return; if (!video || selected || bulkState) return;
if (hoverTimer) clearTimeout(hoverTimer); if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => { hoverTimer = setTimeout(() => {
hoverPlaying = true; hoverPlaying = true;
@@ -61,6 +77,7 @@
const tilePx = $derived(view.thumbnailSize); const tilePx = $derived(view.thumbnailSize);
const src1x = $derived(thumbSrc(hash, tilePx)); const src1x = $derived(thumbSrc(hash, tilePx));
const srcset = $derived(thumbSrcSet(hash, tilePx)); const srcset = $derived(thumbSrcSet(hash, tilePx));
const bulkState = $derived(bulkPhotoStates.get(photo.UID));
</script> </script>
<!-- <!--
@@ -138,11 +155,65 @@
{#if selected} {#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div> <div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if} {/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)} {#if isVideo(photo)}
<span <span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground" class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span >VIDEO</span
> >
{/if} {/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>
<!--
Favorite heart — a *sibling* of the tile button (nested buttons are
invalid HTML and break click semantics). Filled + always visible when
favorited; otherwise fades in on hover. Mirrors the `f` shortcut.
-->
<button
type="button"
class="absolute bottom-1.5 right-1.5 z-10 rounded-full bg-background/70 p-1 backdrop-blur transition-opacity {photo.Favorite
? 'opacity-100'
: 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100'}"
onclick={(e) => {
e.stopPropagation();
void toggleFavorite([photo.UID]);
}}
ondblclick={(e) => e.stopPropagation()}
title={photo.Favorite ? "Remove from favorites (f)" : "Add to favorites (f)"}
aria-pressed={photo.Favorite ?? false}
aria-label="Favorite"
>
<Heart
class="h-3.5 w-3.5 {photo.Favorite ? 'text-red-500' : 'text-foreground/80'}"
fill={photo.Favorite ? "currentColor" : "none"}
/>
</button> </button>
</div> </div>

View File

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

View File

@@ -9,7 +9,12 @@ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30_000, staleTime: 30_000,
retry: 1 retry: 1,
// The indexer WebSocket (stores/indexer.svelte.ts) already
// invalidates ['photos'] and friends on live changes, so a
// window-focus refetch only adds a redundant full-timeline
// re-render (visible flash) every time the tab regains focus.
refetchOnWindowFocus: false
} }
} }
}); });

View File

@@ -24,10 +24,14 @@ export interface DuplicateGroup {
bestFileUid: string; 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({ const photos = await listPhotos({
q: 'stack:true', q,
count: 200, count: 500,
merged: true, merged: true,
order: 'newest' order: 'newest'
}); });

View File

@@ -53,7 +53,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
title: 'Implausible year', title: 'Implausible year',
chip: 'bad year', chip: 'bad year',
suggestion: suggestion:
"Filenames suggest a date PhotoPrism doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.", "Filenames suggest a date the indexer doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
suggestedAction: 'manual' suggestedAction: 'manual'
}, },
non_image_type: { non_image_type: {
@@ -66,7 +66,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
title: 'Other quality issues', title: 'Other quality issues',
chip: 'low quality', chip: 'low quality',
suggestion: suggestion:
'PhotoPrism flagged these but the metadata looks fine. Open the first one to investigate.', 'The indexer flagged these but the metadata looks fine. Open the first one to investigate.',
suggestedAction: 'manual' suggestedAction: 'manual'
} }
}; };

View File

@@ -13,7 +13,7 @@ export interface BatchResult<T> {
export interface BatchOptions { export interface BatchOptions {
concurrency?: number; concurrency?: number;
onProgress?: (done: number, total: number) => void; onProgress?: (done: number, total: number, completedId: string) => void;
} }
export async function batchEdit<T>( 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) }); errors.push({ id, message: err instanceof Error ? err.message : String(err) });
} finally { } finally {
done++; 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 * 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 * function (used by keyword merges which need to read each photo's current
@@ -57,20 +82,22 @@ export async function patchTargets(
) )
: null; : null;
const tid = toast.loading(`${label} · ${ids.length}`);
const { updated, errors } = await batchEdit(ids, async (id) => { const { updated, errors } = await batchEdit(ids, async (id) => {
const p = await freshPhoto(id); const p = await freshPhoto(id);
const body = typeof patch === 'function' ? patch(p) : patch; 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; if (Object.keys(body).length === 0) return p;
return updatePhoto(p, body); return updatePhoto(p, body);
}); });
invalidatePhotos(ids); invalidatePhotos(ids);
invalidateFacets();
if (errors.length) { if (errors.length) {
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`); toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
} else { } else {
toast.success(`${label} · ${ids.length}`); toast.success(`${label} · ${ids.length}`, { id: tid });
} }
if (inverses) { if (inverses) {

View File

@@ -0,0 +1,230 @@
/**
* Resolve/undo logic for the Stacks & Duplicates review tabs.
*
* Both tabs share one loser fate: files move to the sidecar's
* `.duplicates/<timestamp>/` quarantine (recoverable), never a hard
* delete. Stacks additionally promote the keeper to Primary first so
* the surviving Photo row stays coherent while PhotoPrism's async
* cleanup reindex catches up.
*
* Optimistic model: the resolved group is removed from the TanStack
* cache immediately (no refetch), and its identity is remembered in a
* session-level `resolved*` set. The set matters because quarantined
* stack files linger in PhotoPrism's DB until the async reindex
* completes — a plain refetch inside that window would resurrect the
* group. Undo reverses all three: restores the files via the sidecar,
* re-inserts the group into the cache, and forgets the identity.
*/
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { queryClient } from '$lib/queryClient';
import {
archiveDuplicatePaths,
restoreDuplicatePaths,
setPrimary,
type CrossFolderDuplicateGroup,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { userLibraryBase } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
/** Stack photo UIDs / cross-folder hashes resolved this session. Views
* filter refetched lists through these so groups don't resurrect while
* PhotoPrism's cleanup reindex is still running. SvelteSet so the
* filtering is reactive to undo. */
export const resolvedStackUids = new SvelteSet<string>();
export const resolvedCrossHashes = new SvelteSet<string>();
/** Running session tally for the progress header. */
export const dupSession = $state({ resolved: 0, freedBytes: 0 });
function stacksKey(): (string | undefined)[] {
return ['duplicates', userLibraryBase()];
}
function crossKey(): (string | undefined)[] {
return ['duplicates-cross-folder', userLibraryBase()];
}
export function formatBytes(bytes: number): string {
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
if (bytes > 0) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return '0 KB';
}
function bumpSession(freed: number, dir: 1 | -1): void {
dupSession.resolved = Math.max(0, dupSession.resolved + dir);
dupSession.freedBytes = Math.max(0, dupSession.freedBytes + dir * freed);
}
/** Remove/re-insert a stack group in the cached list. */
function patchStacksCache(mutate: (list: DuplicateGroup[]) => DuplicateGroup[]): void {
queryClient.setQueryData<DuplicateGroup[]>(stacksKey(), (list) =>
list ? mutate(list) : list
);
}
function patchCrossCache(
mutate: (groups: CrossFolderDuplicateGroup[]) => CrossFolderDuplicateGroup[]
): void {
queryClient.setQueryData<CrossFolderScanResult>(crossKey(), (res) =>
res ? { ...res, groups: mutate(res.groups) } : res
);
}
function insertAt<T>(list: T[], item: T, index: number): T[] {
const i = Math.min(Math.max(0, index), list.length);
return [...list.slice(0, i), item, ...list.slice(i)];
}
/**
* Resolve a stack: promote `keeperUid` to Primary, quarantine every
* other file's on-disk copy. Returns true on success (view advances
* focus on true).
*/
export async function resolveStack(group: DuplicateGroup, keeperUid: string): Promise<boolean> {
const uid = group.photo.UID;
const losers = group.files.filter((f) => f.UID !== keeperUid);
if (losers.length === 0) return false;
const loserPaths = losers.map((f) => f.Name).filter((n): n is string => !!n);
const freed = losers.reduce((s, f) => s + (f.Size ?? 0), 0);
// Optimistic removal + session bookkeeping.
let removedIndex = 0;
patchStacksCache((list) => {
removedIndex = Math.max(0, list.findIndex((g) => g.photo.UID === uid));
return list.filter((g) => g.photo.UID !== uid);
});
resolvedStackUids.add(uid);
bumpSession(freed, 1);
const rollback = () => {
resolvedStackUids.delete(uid);
bumpSession(freed, -1);
patchStacksCache((list) =>
list.some((g) => g.photo.UID === uid) ? list : insertAt(list, group, removedIndex)
);
};
try {
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
if (keeperUid !== currentPrimary) {
await setPrimary(uid, keeperUid);
}
const result = await archiveDuplicatePaths(loserPaths);
if (result.moved.length === 0) {
rollback();
toast.error('Resolve failed', { description: result.errors[0]?.error });
return false;
}
const undo = async () => {
try {
const res = await restoreDuplicatePaths(result.moved);
if (res.errors.length > 0) {
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
description: res.errors[0].error
});
}
// Restoring undid a real filesystem move even if some files
// failed partway — reflect it in the list either way.
rollback();
} catch (err) {
// Network/HTTP failure — the quarantine move is still intact
// on disk, so don't resurrect the group in the UI; the files
// remain safely recoverable under .duplicates/ by hand.
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(`Resolved stack (${group.files.length} files)`, undo);
if (result.errors.length > 0) {
toast.warning(`Kept 1 · quarantined ${result.moved.length}, ${result.errors.length} failed`, {
description: result.errors[0].error
});
} else {
toast.success(`Kept 1 of ${group.files.length} · ${formatBytes(freed)} freed`, {
action: { label: 'Undo', onClick: () => void undo() }
});
}
// Files moved on disk; photo counts/thumbs may shift once the
// cleanup reindex lands. Background-invalidate the timeline only.
void queryClient.invalidateQueries({ queryKey: ['photos'] });
return true;
} catch (err) {
rollback();
toast.error(err instanceof Error ? err.message : 'Resolve failed');
return false;
}
}
/**
* Resolve a cross-folder group: quarantine every copy except
* `keeperPath`. Returns true on success.
*/
export async function resolveCrossFolder(
group: CrossFolderDuplicateGroup,
keeperPath: string
): Promise<boolean> {
const losers = group.files.filter((f) => f.path !== keeperPath);
if (losers.length === 0) return false;
const freed = losers.reduce((s, f) => s + f.size, 0);
const losingIndexed = !!group.indexedPath && losers.some((f) => f.path === group.indexedPath);
let removedIndex = 0;
patchCrossCache((groups) => {
removedIndex = Math.max(0, groups.findIndex((g) => g.hash === group.hash));
return groups.filter((g) => g.hash !== group.hash);
});
resolvedCrossHashes.add(group.hash);
bumpSession(freed, 1);
const rollback = () => {
resolvedCrossHashes.delete(group.hash);
bumpSession(freed, -1);
patchCrossCache((groups) =>
groups.some((g) => g.hash === group.hash) ? groups : insertAt(groups, group, removedIndex)
);
};
try {
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
if (result.moved.length === 0) {
rollback();
toast.error('Archive failed', { description: result.errors[0]?.error });
return false;
}
const undo = async () => {
try {
const res = await restoreDuplicatePaths(result.moved);
if (res.errors.length > 0) {
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
description: res.errors[0].error
});
}
rollback();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(`Archived ${result.moved.length} duplicate(s)`, undo);
if (result.errors.length > 0) {
toast.warning(`Archived ${result.moved.length}, ${result.errors.length} failed`, {
description: result.errors[0].error
});
} else {
toast.success(`Archived ${result.moved.length} · ${formatBytes(freed)} freed`, {
description: losingIndexed
? 'The previously-indexed copy was moved; the indexer drops it on the next pass.'
: undefined,
action: { label: 'Undo', onClick: () => void undo() }
});
}
void queryClient.invalidateQueries({ queryKey: ['photos'] });
return true;
} catch (err) {
rollback();
toast.error(err instanceof Error ? err.message : 'Archive failed');
return false;
}
}

View File

@@ -14,10 +14,51 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { batchEdit } from './batch'; import { batchEdit } from './batch';
import { invalidatePhotos } from './bulk'; import { invalidatePhotos } from './bulk';
import { approvePhoto, batchArchive, batchRestore } from './photoprism'; import {
approvePhoto,
batchArchive,
batchRestore,
buildTakenAtPatch,
likePhoto,
unlikePhoto,
updatePhoto
} from './photoprism';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte'; import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
import { push as pushUndo } from '$lib/stores/undo.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 * Dismiss photos out of the review queue by bumping their quality
@@ -27,21 +68,123 @@ import { push as pushUndo } from '$lib/stores/undo.svelte';
*/ */
export async function dismissPhotos(uids: string[]): Promise<void> { export async function dismissPhotos(uids: string[]): Promise<void> {
if (uids.length === 0) return; if (uids.length === 0) return;
const tid = toast.loading(`Dismissing ${uids.length}`);
const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id)); 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); focusAfter(uids);
clearSelection(); clearSelection();
invalidatePhotos(uids); invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
if (errors.length) { if (errors.length) {
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, { toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
id: tid,
description: errors[0].message description: errors[0].message
}); });
return; 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`, p));
}
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 });
}
/** Patch `Favorite` on every cached copy of the uids (timeline pages,
* per-photo detail) so hearts flip instantly without a refetch. */
function patchFavoriteCaches(uids: string[], value: boolean): void {
const target = new Set(uids);
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
for (const [key, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
queryClient.setQueryData(
key,
(data as PpPhoto[]).map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
);
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
queryClient.setQueryData(key, {
...(data as object),
pages: pages.map((pg) =>
pg.map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
)
});
}
for (const uid of uids) {
const p = queryClient.getQueryData<PpPhoto>(['photo', uid]);
if (p) queryClient.setQueryData(['photo', uid], { ...p, Favorite: value });
}
}
/**
* Toggle PhotoPrism's native favorite flag on a set of photos. Target
* state comes from the first uid (mixed selections converge). Optimistic
* cache flip with rollback; undo re-toggles.
*/
export async function toggleFavorite(uids: string[]): Promise<void> {
if (uids.length === 0) {
toast.message('Nothing to favorite', {
description: 'Click a photo or select some first'
});
return;
}
const value = !(cachedPhoto(uids[0])?.Favorite ?? false);
patchFavoriteCaches(uids, value);
const { errors } = await batchEdit(uids, (id) => (value ? likePhoto(id) : unlikePhoto(id)));
if (errors.length) {
patchFavoriteCaches(uids, !value);
toast.error(`Favorite failed on ${errors.length}`, { description: errors[0].message });
return;
}
toast.success(
value
? uids.length === 1
? 'Added to favorites'
: `Favorited ${uids.length}`
: uids.length === 1
? 'Removed from favorites'
: `Unfavorited ${uids.length}`
);
pushUndo(value ? `Favorited ${uids.length}` : `Unfavorited ${uids.length}`, async () => {
patchFavoriteCaches(uids, !value);
await batchEdit(uids, (id) => (value ? unlikePhoto(id) : likePhoto(id)));
});
} }
/** /**
@@ -49,10 +192,11 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
*/ */
export async function archivePhotos(uids: string[]): Promise<void> { export async function archivePhotos(uids: string[]): Promise<void> {
if (uids.length === 0) return; if (uids.length === 0) return;
const tid = toast.loading(`Archiving ${uids.length}`);
try { try {
await batchArchive(uids); await batchArchive(uids);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed'); toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
return; return;
} }
pushUndo(`Archived ${uids.length}`, async () => { pushUndo(`Archived ${uids.length}`, async () => {
@@ -64,5 +208,5 @@ export async function archivePhotos(uids: string[]): Promise<void> {
clearSelection(); clearSelection();
invalidatePhotos(uids); invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
toast.success(`Archived ${uids.length}`); toast.success(`Archived ${uids.length}`, { id: tid });
} }

View File

@@ -7,12 +7,14 @@ import {
session, session,
toOriginalsPath, toOriginalsPath,
toUserPath, toUserPath,
userBasePath userBasePath,
userLibraryBase
} from '$lib/stores/session.svelte'; } from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism'; import { primaryFile } from '$lib/types/photoprism';
import type { import type {
PpClientConfig, PpClientConfig,
PpPhoto, PpPhoto,
PpRole,
PpSessionResponse, PpSessionResponse,
PpUser PpUser
} from '$lib/types/photoprism'; } from '$lib/types/photoprism';
@@ -27,6 +29,14 @@ const http: AxiosInstance = axios.create({
headers: { 'Content-Type': 'application/json' } 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) => { http.interceptors.request.use((config) => {
if (session.accessToken) { if (session.accessToken) {
config.headers = config.headers ?? {}; config.headers = config.headers ?? {};
@@ -35,6 +45,14 @@ http.interceptors.request.use((config) => {
return 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( http.interceptors.response.use(
(r) => r, (r) => r,
(err: AxiosError) => { (err: AxiosError) => {
@@ -50,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 ───────────────────────────────────────────────────────────────────── // ── Auth ─────────────────────────────────────────────────────────────────────
export async function login(username: string, password: string): Promise<PpSessionResponse> { export async function login(username: string, password: string): Promise<PpSessionResponse> {
@@ -143,7 +175,7 @@ export interface ListPhotosParams {
} }
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> { 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: { params: {
count: 60, count: 60,
offset: 0, offset: 0,
@@ -155,6 +187,115 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
return data; 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 * Count photos matching a DSL query, scoped to whatever the caller's
* session ACL allows. PhotoPrism doesn't expose a dedicated "count * session ACL allows. PhotoPrism doesn't expose a dedicated "count
@@ -168,10 +309,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
* the signed-in user actually sees, not the global library aggregate * the signed-in user actually sees, not the global library aggregate
* exposed by `/config.count`. * exposed by `/config.count`.
*/ */
export async function countPhotos(q: string): Promise<number> { export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
const resp = await http.get('/photos', { const merged = opts.merged ?? false;
params: { count: 10000, offset: 0, merged: false, q } 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 header = resp.headers['x-count'];
const n = typeof header === 'string' ? parseInt(header, 10) : NaN; const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
@@ -195,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
*/ */
export interface UpdatePhotoBody { export interface UpdatePhotoBody {
OriginalName?: string; OriginalName?: string;
Title?: string;
TitleSrc?: 'manual' | '';
Caption?: string; Caption?: string;
CaptionSrc?: 'manual' | ''; CaptionSrc?: 'manual' | '';
Archived?: boolean; Archived?: boolean;
@@ -225,17 +375,43 @@ export function isValidISODate(s: string): boolean {
return d.toISOString().slice(0, 10) === s; return d.toISOString().slice(0, 10) === s;
} }
export function buildTakenAtPatch(iso: string): UpdatePhotoBody { /** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
* semantically wall-clock time in the photo's TimeZone. Force-parse as
* UTC so offset math never picks up the *browser's* timezone. */
function parseAsUtc(s: string): number {
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
}
/**
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
* UTC↔local offset survives the edit. Without it (or without a prior
* pair) local falls back to UTC — correct for TimeZone-less photos.
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
* clobber manual edits when recomputing local time from TimeZone and
* shifted Year/Month/Day for photos taken far from UTC.
*/
export function buildTakenAtPatch(
iso: string,
photo?: { TakenAt?: string; TakenAtLocal?: string }
): UpdatePhotoBody {
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return {}; if (Number.isNaN(d.getTime())) return {};
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z'); const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
let offsetMs = 0;
if (photo?.TakenAt && photo?.TakenAtLocal) {
const a = parseAsUtc(photo.TakenAt);
const b = parseAsUtc(photo.TakenAtLocal);
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
}
const local = new Date(d.getTime() + offsetMs);
return { return {
TakenAt: utc, TakenAt: utc,
TakenAtLocal: utc, TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
TakenSrc: 'manual', TakenSrc: 'manual',
Year: d.getUTCFullYear(), // PhotoPrism derives Year/Month/Day from local wall-clock time.
Month: d.getUTCMonth() + 1, Year: local.getUTCFullYear(),
Day: d.getUTCDate() Month: local.getUTCMonth() + 1,
Day: local.getUTCDate()
}; };
} }
@@ -356,20 +532,45 @@ export interface PpFolder {
* row itself is dropped — the sidebar synthesises the root entry. When * row itself is dropped — the sidebar synthesises the root entry. When
* BasePath is empty (today's admin default) this is a no-op. * BasePath is empty (today's admin default) this is a no-op.
*/ */
export async function listFolders(): Promise<PpFolder[]> { async function fetchFolders(): Promise<PpFolder[]> {
const { data } = await http.get<{ folders?: PpFolder[] }>( const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/folders/originals', '/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } } { params: { recursive: true, uncached: true, files: false } }
); );
const bp = userBasePath(); return data.folders ?? [];
const folders = data.folders ?? []; }
if (bp === '') return 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 return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/')) .filter((f) => f.Path === base || f.Path.startsWith(base + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) })) .map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) }))
.filter((f) => f.Path !== ''); .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` * Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, so the count has * endpoint reports `FileCount: 0` even when populated, so the count has
@@ -399,7 +600,7 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
// on the way out, then re-key the response back to user-relative on // 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. // the way in so callers' map keys line up with their input array.
const serverPaths = paths.map((p) => toOriginalsPath(p)); 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, string,
number number
>; >;
@@ -410,35 +611,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
return out; return out;
} }
// ── Geo ────────────────────────────────────────────────────────────────────── // ── Countries ────────────────────────────────────────────────────────────────
export interface PpGeoFeature { export interface PpCountry {
type: 'Feature'; Code: string;
id: string; PhotoCount: number;
geometry: { type: 'Point'; coordinates: [number, number] }; Thumb?: string;
properties: {
UID: string;
Hash: string;
Title?: string;
TakenAt?: string;
FavId?: number;
};
} }
export interface PpGeoCollection { export async function listCountries(): Promise<PpCountry[]> {
type: 'FeatureCollection'; // Self-contained sidecar aggregation (groups photos.photo_country directly,
features: PpGeoFeature[]; // no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the
bbox?: number[]; // caller's BasePath the same way /labels and /counts are.
} const { data } = await sidecar.get<PpCountry[]>('/api/sidecar/countries');
export async function listGeo(q = ''): Promise<PpGeoCollection> {
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
// matching geocoded photo. MapLibre's native clustering handles 50k+
// points without breaking a sweat (PhotoPrism upstream documents
// 500k); we ask for a generous cap that covers realistic libraries.
const { data } = await http.get<PpGeoCollection>('/geo', {
params: { count: 50000, q: q || undefined }
});
return data; return data;
} }
@@ -477,6 +662,32 @@ export interface AggregatedKeyword {
sampleHash: string; 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[]> { export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true }); const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>(); const buckets = new Map<string, AggregatedKeyword>();
@@ -519,12 +730,84 @@ export async function listLabels(): Promise<PpLabel[]> {
// them out and the tags page silently shows only ~40% of the user's // 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 // real tag set. `count` bumped to 1000 so a moderately tagged library
// returns the full list in one round-trip. // 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; return data;
} }
// ── Subjects (people / face recognition) ────────────────────────────────────
//
// PhotoPrism's face indexer clusters detected faces into Subjects, each with a
// stable UID, a human-editable Name, and a slug. The DSL operator `person:<slug>`
// filters photos to those carrying a marker assigned to that subject.
export interface PpSubject {
UID: string;
Slug: string;
Name: string;
Favorite?: boolean;
Private?: boolean;
Excluded?: boolean;
PhotoCount?: number;
Thumb?: string;
}
export async function listSubjects(): Promise<PpSubject[]> {
// Sidecar proxy scopes PhotoCount (and drops out-of-scope people) with
// one SQL pass, replacing the old client-side probe-per-subject filter.
const { data } = await sidecar.get<PpSubject[]>('/api/sidecar/subjects', {
params: { count: 1000, order: 'count' }
});
return data ?? [];
}
// ── Face clusters (unnamed people) ──────────────────────────────────────────
//
// PhotoPrism only creates a Subject once someone names a detected face
// cluster. The sidecar lists clusters awaiting a name (scoped to the
// caller's BasePath); naming goes through PhotoPrism's own flow — a PUT
// on the cluster's representative marker — which creates the Subject and
// propagates it across the whole cluster.
export interface UnnamedFaceCluster {
faceId: string;
count: number;
/** Marker crop hash — renders via the standard thumb endpoint. */
thumb: string;
markerUid: string;
}
export async function listUnnamedFaces(): Promise<UnnamedFaceCluster[]> {
const { data } = await sidecar.get<{ clusters: UnnamedFaceCluster[] }>(
'/api/sidecar/faces/unnamed'
);
return data?.clusters ?? [];
}
export async function nameFaceCluster(markerUid: string, name: string): Promise<void> {
await http.put(`/markers/${encodeURIComponent(markerUid)}`, {
Name: name,
SubjSrc: 'manual'
});
}
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
const { data } = await http.put<PpSubject>(`/subjects/${uid}`, patch);
return data;
}
export async function deleteSubject(uid: string): Promise<void> {
await http.delete(`/subjects/${uid}`);
}
// ── Albums = Heaps ─────────────────────────────────────────────────────────── // ── Albums = Heaps ───────────────────────────────────────────────────────────
export interface PpAlbum { export interface PpAlbum {
@@ -651,7 +934,7 @@ export interface RenameResult {
newRelPath: string; 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}`, { const res = await fetch(`/api/sidecar${urlPath}`, {
method, method,
headers: { headers: {
@@ -669,20 +952,20 @@ async function sidecar(method: string, urlPath: string, body?: unknown): Promise
} }
export async function createFolder(relPath: string): Promise<{ path: string }> { 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( export async function renameFolder(
relPath: string, relPath: string,
newName: string newName: string
): Promise<{ oldPath: string; newPath: string }> { ): Promise<{ oldPath: string; newPath: string }> {
return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, { return callSidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
newName newName
}) as Promise<{ oldPath: string; newPath: string }>; }) as Promise<{ oldPath: string; newPath: string }>;
} }
export async function deleteFolder(relPath: string): Promise<{ path: 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; path: string;
}>; }>;
} }
@@ -697,6 +980,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
export interface DupFileEntry { export interface DupFileEntry {
path: string; path: string;
size: number; size: number;
/** RFC3339 mtime — the only per-copy signal besides path, since all
* copies in a group are byte-identical. */
modTime?: string;
} }
export interface CrossFolderDuplicateGroup { export interface CrossFolderDuplicateGroup {
@@ -715,7 +1001,7 @@ export interface CrossFolderScanResult {
} }
export async function scanCrossFolderDuplicates(): Promise<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 { export interface ArchiveDuplicatesResult {
@@ -726,7 +1012,22 @@ export interface ArchiveDuplicatesResult {
export async function archiveDuplicatePaths( export async function archiveDuplicatePaths(
paths: string[] paths: string[]
): Promise<ArchiveDuplicatesResult> { ): Promise<ArchiveDuplicatesResult> {
return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>; return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
}
export interface RestoreDuplicatesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of archiveDuplicatePaths: pass the `moved` pairs from the
* archive response verbatim and the sidecar renames each quarantined
* file back to its original path. Powers undo for duplicate/stack
* resolution. */
export async function restoreDuplicatePaths(
moves: { from: string; to: string }[]
): Promise<RestoreDuplicatesResult> {
return callSidecar('POST', '/duplicates/restore', { moves }) as Promise<RestoreDuplicatesResult>;
} }
// ── Heap convert (move/copy heap photos to a folder) ──────────────────────── // ── Heap convert (move/copy heap photos to a folder) ────────────────────────
@@ -750,6 +1051,8 @@ export interface HeapConvertBody {
export interface HeapConvertResult { export interface HeapConvertResult {
moved: number; moved: number;
copied: number; copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[]; errors: { uid: string; reason: string }[];
heap_deleted: boolean; heap_deleted: boolean;
} }
@@ -758,7 +1061,73 @@ export async function convertHeap(
uid: string, uid: string,
body: HeapConvertBody body: HeapConvertBody
): Promise<HeapConvertResult> { ): 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;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[];
}
export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMoveResult> {
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
}
export interface RestoreMovesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of a photo/heap move: pass the `movedFiles` pairs from the
* move response verbatim and the sidecar renames each file back to its
* original folder (both ends scope-checked, no clobbering). Powers ⌘Z
* undo for moves. */
export async function restoreMoves(
moves: { from: string; to: string }[]
): Promise<RestoreMovesResult> {
return callSidecar('POST', '/files/restore-moves', { moves }) as Promise<RestoreMovesResult>;
}
// ── 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>;
}
// ── Favorites ────────────────────────────────────────────────────────────────
// PhotoPrism's native favorite flag — unlike marks, this syncs to any
// PhotoPrism-compatible client app.
export async function likePhoto(uid: string): Promise<void> {
await http.post(`/photos/${encodeURIComponent(uid)}/like`);
}
export async function unlikePhoto(uid: string): Promise<void> {
await http.delete(`/photos/${encodeURIComponent(uid)}/like`);
} }
// ── Photo marks (rating + color) ───────────────────────────────────────────── // ── Photo marks (rating + color) ─────────────────────────────────────────────
@@ -774,19 +1143,19 @@ export interface PhotoMark {
export type PhotoMarksMap = Record<string, PhotoMark>; export type PhotoMarksMap = Record<string, PhotoMark>;
export async function getAllMarks(): Promise<PhotoMarksMap> { export async function getAllMarks(): Promise<PhotoMarksMap> {
const data = await sidecar('GET', '/photos/marks'); const data = await callSidecar('GET', '/photos/marks');
return (data ?? {}) as PhotoMarksMap; return (data ?? {}) as PhotoMarksMap;
} }
export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> { 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( export async function bulkSetMarks(
ids: string[], ids: string[],
patch: PhotoMark patch: PhotoMark
): Promise<{ count: number; marks: PhotoMarksMap }> { ): 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; count: number;
marks: PhotoMarksMap; marks: PhotoMarksMap;
}>; }>;
@@ -811,28 +1180,26 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
// ── Settings / Admin ───────────────────────────────────────────────────────── // ── Settings / Admin ─────────────────────────────────────────────────────────
// //
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog // 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 // 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. // 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 { export interface PpSettings {
ui?: { index?: {
theme?: string; path?: string;
language?: string; convert?: boolean;
timeZone?: string; rescan?: boolean;
startPage?: string; skipArchived?: boolean;
scrollbar?: boolean; skipMeta?: boolean;
zoom?: boolean; skipRaw?: boolean;
skipHidden?: boolean;
}; };
search?: {
batchSize?: number;
listView?: boolean;
showTitles?: boolean;
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean }; stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: { download?: {
name?: string; name?: string;
@@ -840,10 +1207,30 @@ export interface PpSettings {
originals?: boolean; originals?: boolean;
mediaRaw?: boolean; mediaRaw?: boolean;
mediaSidecar?: boolean; mediaSidecar?: boolean;
crc32?: boolean;
sha1?: boolean;
}; };
[k: string]: unknown; [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> { export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings'); const { data } = await http.get<PpSettings>('/settings');
return data; return data;
@@ -874,26 +1261,6 @@ export async function cancelIndex(): Promise<void> {
await http.delete('/index'); 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 { export interface PpLogEntry {
Time: string; Time: string;
Level: string; Level: string;
@@ -907,6 +1274,55 @@ export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEnt
return data ?? []; return data ?? [];
} }
// ── Users ────────────────────────────────────────────────────────────────────
//
// PhotoPrism's admin user endpoints. List/create/update/delete require an
// admin session; the password endpoint accepts the user's own UID with their
// current password as `old`.
export interface CreateUserBody {
Name: string;
DisplayName?: string;
Email?: string;
Role: PpRole;
BasePath?: string;
UploadPath?: string;
WebDAV?: boolean;
Password?: string;
}
export type UpdateUserBody = Partial<CreateUserBody>;
export async function listUsers(): Promise<PpUser[]> {
const { data } = await http.get<PpUser[] | { users?: PpUser[] }>('/users', {
params: { count: 1000, order: 'name' }
});
if (Array.isArray(data)) return data;
return data.users ?? [];
}
export async function createUser(body: CreateUserBody): Promise<PpUser> {
const { data } = await http.post<PpUser>('/users', body);
return data;
}
export async function updateUser(uid: string, patch: UpdateUserBody): Promise<PpUser> {
const { data } = await http.put<PpUser>(`/users/${uid}`, patch);
return data;
}
export async function deleteUser(uid: string): Promise<void> {
await http.delete(`/users/${uid}`);
}
export async function setUserPassword(
uid: string,
oldPassword: string,
newPassword: string
): Promise<void> {
await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword });
}
// ── Re-exports ─────────────────────────────────────────────────────────────── // ── Re-exports ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser }; export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };

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,13 +17,21 @@ export type Section =
| 'hidden' | 'hidden'
| 'heap'; | 'heap';
export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings'; export type TagCategory =
| 'labels'
| 'keywords'
| 'people'
| 'colors'
| 'ratings'
| 'countries';
export const TAG_CATEGORIES: readonly TagCategory[] = [ export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels', 'labels',
'keywords', 'keywords',
'people',
'colors', 'colors',
'ratings' 'ratings',
'countries'
] as const; ] as const;
export function isTagCategory(v: unknown): v is TagCategory { export function isTagCategory(v: unknown): v is TagCategory {
@@ -33,6 +41,25 @@ export function isTagCategory(v: unknown): v is TagCategory {
); );
} }
export type SortOrder = 'newest' | 'oldest' | 'added' | 'name';
export const SORT_ORDERS: readonly SortOrder[] = ['newest', 'oldest', 'added', 'name'] as const;
export const SORT_LABELS: Record<SortOrder, string> = {
newest: 'Newest first',
oldest: 'Oldest first',
added: 'Recently added',
name: 'File name'
};
/** Media-type chip values → PhotoPrism boolean q-DSL filters. */
export type MediaType = 'photo' | 'video' | 'raw' | 'live';
export const MEDIA_TYPES: readonly MediaType[] = ['photo', 'video', 'raw', 'live'] as const;
export const MEDIA_TYPE_LABELS: Record<MediaType, string> = {
photo: 'Photos',
video: 'Videos',
raw: 'RAW',
live: 'Live'
};
export interface FilterState { export interface FilterState {
section: Section; section: Section;
/** Heap UID, used when section === 'heap'. */ /** Heap UID, used when section === 'heap'. */
@@ -41,6 +68,14 @@ export interface FilterState {
folderPath: string | null; folderPath: string | null;
/** Free-form search text, ANDed with section-derived terms. */ /** Free-form search text, ANDed with section-derived terms. */
search: string; search: string;
/** Timeline sort order. Maps straight onto PhotoPrism's `order` param. */
sort: SortOrder;
/** Media-type chip; null = any. */
mediaType: MediaType | null;
/** Year chip; null = any. Compiles to `year:<n>`. */
year: number | null;
/** Favorites-only chip. Compiles to `favorite:true`. */
favorite: boolean;
/** /**
* Active tag-browser category and selected value. Set by the * Active tag-browser category and selected value. Set by the
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords * `/tags/[category]/[[value]]` route on navigation. Labels/keywords
@@ -60,10 +95,42 @@ export const filters = $state<FilterState>({
heapUid: null, heapUid: null,
folderPath: '/', folderPath: '/',
search: '', search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: null, tagCategory: null,
tagValue: null tagValue: null
}); });
export function setSort(sort: SortOrder): void {
filters.sort = sort;
}
export function setMediaType(t: MediaType | null): void {
filters.mediaType = t;
}
export function setYear(y: number | null): void {
filters.year = y;
}
export function setFavorite(on: boolean): void {
filters.favorite = on;
}
/** True when any toolbar chip narrows the view (excludes sort — a sort
* isn't a filter). Drives the "Clear" affordance. */
export function chipsActive(f: FilterState = filters): boolean {
return f.mediaType !== null || f.year !== null || f.favorite;
}
export function clearChips(): void {
filters.mediaType = null;
filters.year = null;
filters.favorite = false;
}
export function setSection(section: Section, heapUid: string | null = null): void { export function setSection(section: Section, heapUid: string | null = null): void {
filters.section = section; filters.section = section;
filters.heapUid = section === 'heap' ? heapUid : null; filters.heapUid = section === 'heap' ? heapUid : null;
@@ -85,6 +152,59 @@ export function setTagFilter(
filters.tagValue = value; 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 * Navigate to a tag-category browse URL. Path-segment shape
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's * (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
@@ -176,15 +296,32 @@ export function filtersToQ(f: FilterState = filters): string {
} }
// Tag drill-down clauses for server-resolvable tag categories. // Tag drill-down clauses for server-resolvable tag categories.
// Colors/ratings live in the mule-sidecar marks store and are // Colors/ratings live in the mule-sidecar marks store and are
// applied client-side after the photo pool is fetched. // applied client-side after the photo pool is fetched. People uses
// PhotoPrism's `person:` operator, which accepts the subject's slug.
if (f.tagCategory && f.tagValue) { if (f.tagCategory && f.tagValue) {
if (f.tagCategory === 'labels') { if (f.tagCategory === 'labels') {
parts.push(`label:${quoteIfNeeded(f.tagValue)}`); parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'keywords') { } else if (f.tagCategory === 'keywords') {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`); 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)); // Toolbar chips. PhotoPrism's boolean media filters (`video:true`,
// `photo:true`, …) are the documented DSL forms; `year:` and
// `favorite:` are plain filters.
if (f.mediaType) parts.push(`${f.mediaType}:true`);
if (f.year) parts.push(`year:${f.year}`);
if (f.favorite) parts.push('favorite:true');
// `f.search` is the raw-DSL escape hatch (toolbar cheat-sheet examples
// like `label:dog`, `taken:2024`) as well as plain free text. Only
// quote it when it has no `:` — a colon means the user (or a
// jump-to-search link) already wrote a structured term, and wrapping
// the whole thing in quotes would turn `camera:iPhone` into a literal
// phrase search for the text "camera:iPhone" instead of the operator.
if (f.search) parts.push(f.search.includes(':') ? f.search : quoteIfNeeded(f.search));
return parts.join(' '); return parts.join(' ');
} }
@@ -206,11 +343,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
!params.has('q'); !params.has('q');
const folderRaw = params.get('folder'); const folderRaw = params.get('folder');
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null; const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
const sortRaw = params.get('sort');
const typeRaw = params.get('type');
const yearRaw = params.get('year');
return { return {
section, section,
heapUid: params.get('heap'), heapUid: params.get('heap'),
folderPath, folderPath,
search: params.get('q') ?? '' search: params.get('q') ?? '',
sort: (SORT_ORDERS as readonly string[]).includes(sortRaw ?? '')
? (sortRaw as SortOrder)
: 'newest',
mediaType: (MEDIA_TYPES as readonly string[]).includes(typeRaw ?? '')
? (typeRaw as MediaType)
: null,
year: yearRaw && /^\d{4}$/.test(yearRaw) ? parseInt(yearRaw, 10) : null,
favorite: params.get('fav') === '1'
}; };
} }
@@ -222,5 +370,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
if (f.heapUid) params.set('heap', f.heapUid); if (f.heapUid) params.set('heap', f.heapUid);
if (f.folderPath) params.set('folder', f.folderPath); if (f.folderPath) params.set('folder', f.folderPath);
if (f.search) params.set('q', f.search); if (f.search) params.set('q', f.search);
if (f.sort !== 'newest') params.set('sort', f.sort);
if (f.mediaType) params.set('type', f.mediaType);
if (f.year) params.set('year', String(f.year));
if (f.favorite) params.set('fav', '1');
return params; return params;
} }

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { queryClient } from '$lib/queryClient';
import { isAuthenticated, session } from './session.svelte'; import { isAuthenticated, session } from './session.svelte';
/** /**
@@ -44,6 +45,27 @@ let lastFileUpdateAt = 0;
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null; let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
let pendingFileName: string | undefined; 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 { function url(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/v1/ws`; return `${proto}//${location.host}/api/v1/ws`;
@@ -134,6 +156,8 @@ function handleMessage(raw: string): void {
const fileName = const fileName =
(data.fileName as string | undefined) ?? (data.baseName as string | undefined); (data.fileName as string | undefined) ?? (data.baseName as string | undefined);
setActiveThrottled('Indexing', fileName); setActiveThrottled('Indexing', fileName);
// Stream newly indexed files into the grid as the scan runs.
invalidatePhotosGridThrottled();
return; return;
} }
case 'index.updating': { case 'index.updating': {
@@ -148,6 +172,8 @@ function handleMessage(raw: string): void {
case 'index.completed': { case 'index.completed': {
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined; const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete'); setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
// Final refetch so the grid lands on the fully-indexed result.
invalidatePhotosGrid();
return; return;
} }
default: 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 // (Hit this with the `test` user seeing the admin's library counts
// in the left sidebar.) // in the left sidebar.)
queryClient.clear(); 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.id = resp.id;
session.accessToken = resp.access_token; session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? ''; session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
@@ -71,6 +75,7 @@ export function clearSession(): void {
session.previewToken = null; session.previewToken = null;
session.downloadToken = null; session.downloadToken = null;
session.user = null; session.user = null;
prefs.indexSubpath = '';
if (browser) localStorage.removeItem(STORAGE_KEY); if (browser) localStorage.removeItem(STORAGE_KEY);
// Same reasoning as adoptSession — wipe the cache so the next user // Same reasoning as adoptSession — wipe the cache so the next user
// who logs in (or the login screen itself) doesn't render with the // 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 * The signed-in user's library root, originals-relative, no leading/trailing
* slash. `""` means "whole library" — used today by admin accounts whose * slash. `""` means "whole library" — used today by admin accounts whose
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place * BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter, * as set on their PhotoPrism account; the working library root the rest of
* folder counts, heap convert) so each user sees only their own subtree. * the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
*/ */
export function userBasePath(): string { export function userBasePath(): string {
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, ''); 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 * Translate a user-relative path (what the sidebar and URL deal in) to a
* server-absolute, originals-relative path (what PhotoPrism's `path:` * 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) * "" or "/" → libraryBase (user's working root)
* "2024/01" → "<basePath>/2024/01" * "2024/01" → "<libraryBase>/2024/01"
* null → "" (caller decides to omit the filter entirely) * null → "" (caller decides to omit the filter entirely)
*/ */
export function toOriginalsPath(uiPath: string | null): string { export function toOriginalsPath(uiPath: string | null): string {
if (uiPath === null) return ''; if (uiPath === null) return '';
const bp = userBasePath(); const base = userLibraryBase();
const rel = uiPath.replace(/^\/+|\/+$/g, ''); const rel = uiPath.replace(/^\/+|\/+$/g, '');
if (rel === '') return bp; if (rel === '') return base;
return bp === '' ? rel : `${bp}/${rel}`; return base === '' ? rel : `${base}/${rel}`;
} }
/** /**
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the * Inverse of `toOriginalsPath` — strips the effective library-root prefix so
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that * 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). * are equal to the root collapse to `""` (the user's root sentinel). Paths
* Paths outside the BasePath are returned as-is, but callers should * outside the root are returned as-is, but callers should already have
* already have filtered those out via `listFolders`'s post-filter. * filtered those out via `listFolders`'s post-filter.
*/ */
export function toUserPath(serverPath: string): string { export function toUserPath(serverPath: string): string {
const bp = userBasePath(); const base = userLibraryBase();
const sp = serverPath.replace(/^\/+|\/+$/g, ''); const sp = serverPath.replace(/^\/+|\/+$/g, '');
if (bp === '') return sp; if (base === '') return sp;
if (sp === bp) return ''; if (sp === base) return '';
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1); if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
return sp; return sp;
} }

View File

@@ -81,6 +81,10 @@ export const view = $state<{
* persisted — a refresh always returns to the grid. * persisted — a refresh always returns to the grid.
*/ */
previewOpen: boolean; previewOpen: boolean;
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
shortcutsOpen: boolean;
/** Ephemeral: true while the ⌘K command palette is open. */
paletteOpen: boolean;
metadataSections: Record<string, boolean>; metadataSections: Record<string, boolean>;
}>({ }>({
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false, rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
@@ -107,6 +111,8 @@ export const view = $state<{
), ),
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false, tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
previewOpen: false, previewOpen: false,
shortcutsOpen: false,
paletteOpen: false,
metadataSections: metadataSections:
initial.metadataSections && typeof initial.metadataSections === 'object' initial.metadataSections && typeof initial.metadataSections === 'object'
? { ...initial.metadataSections } ? { ...initial.metadataSections }
@@ -164,6 +170,22 @@ export function togglePreview(): void {
view.previewOpen = !view.previewOpen; view.previewOpen = !view.previewOpen;
} }
export function toggleShortcuts(): void {
view.shortcutsOpen = !view.shortcutsOpen;
}
export function closeShortcuts(): void {
view.shortcutsOpen = false;
}
export function togglePalette(): void {
view.paletteOpen = !view.paletteOpen;
}
export function closePalette(): void {
view.paletteOpen = false;
}
export function setThumbnailSize(size: ThumbnailSize): void { export function setThumbnailSize(size: ThumbnailSize): void {
view.thumbnailSize = size; view.thumbnailSize = size;
persist(); persist();

View File

@@ -130,6 +130,7 @@ export interface PpPhoto {
Height?: number; Height?: number;
Rating?: number; Rating?: number;
Color?: string | number; Color?: string | number;
Favorite?: boolean;
Archived?: boolean; Archived?: boolean;
Files?: PpFile[]; Files?: PpFile[];
Lat?: number; Lat?: number;
@@ -213,6 +214,23 @@ export interface PpPhotoLabel {
Label?: { Slug: string; Name: string }; 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 * 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 * first file if no primary marker is set. Falls back to a synthetic entry
@@ -239,10 +257,17 @@ export function isVideo(p: PpPhoto): boolean {
/** Return the Files[] entry that carries the actual video stream. Falls back /** Return the Files[] entry that carries the actual video stream. Falls back
* to primaryFile() if no video MediaType is present (shouldn't happen for * to primaryFile() if no video MediaType is present (shouldn't happen for
* Type === 'video' but keeps the call site total). */ * Type === 'video' but keeps the call site total).
*
* PhotoPrism serializes MediaType as the bare word "video" (verified
* against prod), not a MIME type — the old `startsWith('video/')` check
* never matched, so this always fell back to the JPEG poster and video
* facts (duration/codec/fps) were unreachable. */
export function videoFile(p: PpPhoto): PpFile { export function videoFile(p: PpPhoto): PpFile {
const files = p.Files ?? []; const files = p.Files ?? [];
const v = files.find((f) => f.MediaType?.startsWith('video/')); const v = files.find(
(f) => f.MediaType === 'video' || f.MediaType?.startsWith('video/')
);
return v ?? primaryFile(p); return v ?? primaryFile(p);
} }
@@ -258,6 +283,34 @@ export interface PpFile {
Size?: number; Size?: number;
FileType?: string; FileType?: string;
MediaType?: string; MediaType?: string;
Codec?: string;
/** Video duration in nanoseconds (Go time.Duration serialization). */
Duration?: number;
FPS?: number;
Frames?: number;
/** EXIF orientation 18. */
Orientation?: number;
HDR?: boolean;
Projection?: string;
/** Face/subject markers detected in this file (present on
* GET /photos/:uid responses). */
Markers?: PpMarker[];
}
/** A detected region in a file — for our purposes always a face. Named
* markers carry the subject they were matched to. */
export interface PpMarker {
UID: string;
Type?: string;
Src?: string;
Name?: string;
SubjUID?: string;
SubjSrc?: string;
FaceID?: string;
Invalid?: boolean;
Score?: number;
/** Crop hash renderable via the standard thumb endpoint. */
Thumb?: string;
} }
export type PpThumbSize = export type PpThumbSize =

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'; import type { PpPhoto } from '$lib/types/photoprism';
/** /**
* Lightroom culling convention: red rejects, orange reviews, yellow * Color labels are purely a marking dimension — no semantic meaning is
* picks, green keeps. Order here is the order the TagsBrowser renders * attached. Order here is the order the TagsBrowser renders rows in,
* rows in — fixed so the user can build muscle memory. * 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 }[] = [ export const COLOR_SWATCHES: readonly {
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' }, key: string;
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' }, bg: string;
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' }, border: string;
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' } 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; ] as const;
export interface RatingGroup { export interface RatingGroup {

View File

@@ -13,10 +13,16 @@
import { resizable } from '$lib/actions/resizable'; import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte'; import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
import { bulkAction } from '$lib/stores/bulkAction.svelte';
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.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 LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte'; import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte'; import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
import { togglePalette } from '$lib/stores/view.svelte';
let { children } = $props(); let { children } = $props();
@@ -55,8 +61,18 @@
else stopIndexerWatch(); else stopIndexerWatch();
}); });
// ⌘K lives at the window level (not gridKeyNav) so the palette opens
// from any route and even while a form field holds focus.
function onGlobalKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
togglePalette();
}
}
</script> </script>
<svelte:window onkeydown={onGlobalKey} />
<svelte:head> <svelte:head>
<title>Mulimage</title> <title>Mulimage</title>
</svelte:head> </svelte:head>
@@ -72,6 +88,7 @@
<div class="flex h-screen flex-col overflow-hidden"> <div class="flex h-screen flex-col overflow-hidden">
<AnimatedMule> <AnimatedMule>
<IndexerStatusPill /> <IndexerStatusPill />
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
</AnimatedMule> </AnimatedMule>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
{#if !view.leftSidebarCollapsed} {#if !view.leftSidebarCollapsed}
@@ -114,6 +131,14 @@
helper (called by the timeline / PhotoGrid dblclick paths and helper (called by the timeline / PhotoGrid dblclick paths and
by gridKeyNav's Space handler). --> by gridKeyNav's Space handler). -->
<PreviewModal /> <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 />
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
<ShortcutsDialog />
<!-- ⌘K palette — jump to sections/heaps/folders + global actions. -->
<CommandPalette />
{:else} {:else}
{@render children?.()} {@render children?.()}
{/if} {/if}

View File

@@ -13,15 +13,30 @@
getPhoto, getPhoto,
listHeaps, listHeaps,
listPhotos, listPhotos,
listPhotosAround,
type PpAlbum, type PpAlbum,
} from "$lib/services/photoprism"; } from "$lib/services/photoprism";
import { import {
chipsActive,
clearChips,
consumePendingFocus,
filters, filters,
filtersToQ, filtersToQ,
filtersToUrlParams, filtersToUrlParams,
MEDIA_TYPE_LABELS,
MEDIA_TYPES,
parseUrlParams, parseUrlParams,
setFavorite,
setMediaType,
setSearch, setSearch,
setSection, setSection,
setSort,
setYear,
SORT_LABELS,
SORT_ORDERS,
type MediaType,
type PendingFocus,
type SortOrder,
} from "$lib/stores/filters.svelte"; } from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte"; import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
@@ -34,6 +49,7 @@
setFocused, setFocused,
setOrder, setOrder,
} from "$lib/stores/selection.svelte"; } from "$lib/stores/selection.svelte";
import { removedIds, clearRemoved } from "$lib/stores/bulkAction.svelte";
import { import {
openPreview, openPreview,
setRightSidebarWidth, setRightSidebarWidth,
@@ -56,6 +72,16 @@
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte"; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte"; import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte"; import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { EmptyState, InlineLoader } from "$lib/components/feedback";
import {
AlertCircle,
Archive,
EyeOff,
ImageOff,
Layers,
MousePointerClick,
Sparkles,
} from "lucide-svelte";
import { type PpPhoto } from "$lib/types/photoprism"; import { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
@@ -67,6 +93,10 @@
if (next.heapUid !== undefined) filters.heapUid = next.heapUid; if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.folderPath !== undefined) filters.folderPath = next.folderPath; if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
if (next.search !== undefined) filters.search = next.search; if (next.search !== undefined) filters.search = next.search;
if (next.sort !== undefined) filters.sort = next.sort;
if (next.mediaType !== undefined) filters.mediaType = next.mediaType;
if (next.year !== undefined) filters.year = next.year;
if (next.favorite !== undefined) filters.favorite = next.favorite;
}); });
// When the store changes from in-app actions (left-sidebar nav, search // When the store changes from in-app actions (left-sidebar nav, search
@@ -127,24 +157,95 @@
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten // SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
// downstream into a single `photos` array consumers iterate. // downstream into a single `photos` array consumers iterate.
const PHOTOS_PAGE_SIZE = 120; 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[]>(() => ({ const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }], queryKey: [
queryFn: ({ pageParam }) => "photos",
listPhotos({ "q",
q: filtersToQ(filters), filtersToQ(filters),
{
count: PHOTOS_PAGE_SIZE, count: PHOTOS_PAGE_SIZE,
offset: pageParam as number, anchor: anchor?.takenAt ?? null,
order: "newest", sort: filters.sort,
},
],
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). Anchor windows assume chronological order,
// so any non-default sort falls back to plain paging.
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
return listPhotosAround({
q: baseQ,
takenAt: anchor.takenAt,
afterCount: 30,
beforeCount: 90,
merged: true,
});
}
return listPhotos({
q: baseQ,
count: PHOTOS_PAGE_SIZE,
offset,
order: filters.sort,
merged: true, merged: true,
}), });
},
initialPageParam: 0, initialPageParam: 0,
// PhotoPrism's `count` limits SQL rows; with `merged=true` each // PhotoPrism's `count` limits SQL rows; with `merged=true` each
// photo expands into its file rows, so a "full" page of count=120 // photo expands into its file rows, so a "full" page of count=120
// typically returns ~60 photo entries. The only reliable end-of- // typically returns ~60 photo entries. The only reliable end-of-
// pagination signal is an empty page. Costs one extra fetch at the // pagination signal is an empty page. Costs one extra fetch at the
// tail (cheap; the empty response is small). // tail (cheap; the empty response is small).
getNextPageParam: (last, pages) => getNextPageParam: (last, pages) => {
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE, 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 && filters.sort === "newest") return undefined;
return pages.length * PHOTOS_PAGE_SIZE;
},
enabled: isAuthenticated(), enabled: isAuthenticated(),
})); }));
@@ -166,7 +267,12 @@
const dedupedAll = $derived<PpPhoto[]>( const dedupedAll = $derived<PpPhoto[]>(
dedupedPhotos(photosQuery.data?.pages), 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[] { function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return []; if (!pages) return [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -198,6 +304,20 @@
} }
const pageCount = $derived(photosQuery.data?.pages.length ?? 0); 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(() => { $effect(() => {
setOrder(photos.map((p) => p.UID)); setOrder(photos.map((p) => p.UID));
}); });
@@ -227,7 +347,22 @@
// Only re-anchor focus on the very first page; later pages // Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0]. // must not pull focus back to photo[0].
if (pages !== 1) return; 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);
}
}); });
}); });
@@ -620,6 +755,7 @@
return; return;
} }
emptyingArchive = true; emptyingArchive = true;
const tid = toast.loading("Emptying archive…");
let total = 0; let total = 0;
try { try {
while (true) { while (true) {
@@ -635,9 +771,9 @@
await batchDelete(uids); await batchDelete(uids);
total += uids.length; 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) { } 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 { } finally {
emptyingArchive = false; emptyingArchive = false;
void qc.invalidateQueries({ queryKey: ["photos"] }); void qc.invalidateQueries({ queryKey: ["photos"] });
@@ -685,6 +821,16 @@
setSearch(searchDraft.trim()); setSearch(searchDraft.trim());
} }
// Toolbar chips: years from the current year back to 1990 — static
// range keeps it dependency-free; PhotoPrism just returns an empty
// page for years with no photos.
const CHIP_YEARS = Array.from(
{ length: new Date().getFullYear() - 1989 },
(_, i) => new Date().getFullYear() - i,
);
const CHIP_SELECT_CLASS =
"rounded border border-input bg-background px-1.5 py-0.5 text-[11px] text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring";
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on // PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet. // focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [ const SEARCH_EXAMPLES = [
@@ -728,6 +874,7 @@
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}> <form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input <input
type="search" type="search"
data-search-input
placeholder={'Search · label:website / "vacation"'} placeholder={'Search · label:website / "vacation"'}
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft} bind:value={searchDraft}
@@ -783,6 +930,68 @@
{/if} {/if}
</form> </form>
<!-- Filter chips: sort / media type / year / favorites. Compile into
the same q-DSL the search box feeds, so they stack with search,
folders, and sections. URL-persisted for shareable views. -->
<div class="flex shrink-0 items-center gap-1" role="group" aria-label="Filters">
<select
class={CHIP_SELECT_CLASS}
value={filters.sort}
onchange={(e) => setSort(e.currentTarget.value as SortOrder)}
title="Sort order"
aria-label="Sort order"
>
{#each SORT_ORDERS as s (s)}
<option value={s}>{SORT_LABELS[s]}</option>
{/each}
</select>
<select
class={CHIP_SELECT_CLASS}
value={filters.mediaType ?? ""}
onchange={(e) => setMediaType((e.currentTarget.value || null) as MediaType | null)}
title="Media type"
aria-label="Media type"
>
<option value="">Any type</option>
{#each MEDIA_TYPES as t (t)}
<option value={t}>{MEDIA_TYPE_LABELS[t]}</option>
{/each}
</select>
<select
class={CHIP_SELECT_CLASS}
value={filters.year ? String(filters.year) : ""}
onchange={(e) => setYear(e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : null)}
title="Year"
aria-label="Year"
>
<option value="">Any year</option>
{#each CHIP_YEARS as y (y)}
<option value={String(y)}>{y}</option>
{/each}
</select>
<button
type="button"
class="rounded border px-1.5 py-0.5 text-[11px] transition-colors {filters.favorite
? 'border-red-400 bg-red-500/10 text-red-500'
: 'border-input text-muted-foreground hover:bg-accent'}"
aria-pressed={filters.favorite}
onclick={() => setFavorite(!filters.favorite)}
title="Favorites only (f toggles a photo's favorite)"
>
♥ Favorites
</button>
{#if chipsActive(filters)}
<button
type="button"
class="rounded px-1.5 py-0.5 text-[11px] text-muted-foreground underline-offset-2 hover:underline"
onclick={clearChips}
title="Clear type / year / favorites filters"
>
Clear
</button>
{/if}
</div>
{#snippet trailing()} {#snippet trailing()}
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL. <!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
Persisted to localStorage via view.svelte.ts. --> Persisted to localStorage via view.svelte.ts. -->
@@ -831,29 +1040,42 @@
{#if photosQuery.isPending} {#if photosQuery.isPending}
<SkeletonGrid /> <SkeletonGrid />
{:else if photosQuery.isError} {:else if photosQuery.isError}
<p class="text-sm text-destructive"> <EmptyState
Failed to load photos: {photosQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Failed to load photos"
description={photosQuery.error instanceof Error
? photosQuery.error.message ? photosQuery.error.message
: "unknown error"} : "unknown error"}
</p> />
{:else if photos.length === 0} {:else if photos.length === 0}
<p class="text-sm text-muted-foreground"> {#if filters.section === "archive"}
{#if filters.section === "archive"} <EmptyState icon={Archive} title="Archive is empty" />
Archive is empty. {:else if filters.section === "review"}
{:else if filters.section === "review"} <EmptyState
Nothing left to review. Photos PhotoPrism's indexer wasn't icon={Sparkles}
sure about land here — use Keep to accept them into the title="Nothing left to review"
timeline or Archive to set them aside. description="Photos the indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
{:else if filters.section === "hidden"} />
No hidden photos. PhotoPrism auto-hides files it can't index {:else if filters.section === "hidden"}
(broken files, very low quality); they only ever show up here. <EmptyState
{:else if filters.section === "heap"} icon={EyeOff}
This heap has no photos yet. Select some photos and use the title="No hidden photos"
bulk bar's " Add to heap" button. description="The indexer auto-hides files it can't read (broken files, very low quality); they only ever show up here."
{:else} />
No photos. Index a folder via PhotoPrism's reindex command. {:else if filters.section === "heap"}
{/if} <EmptyState
</p> icon={Layers}
title="This heap has no photos yet"
description={'Select some photos and use the bulk bars “+ Add to heap” button.'}
/>
{:else}
<EmptyState
icon={ImageOff}
title="No photos"
description="Index a folder from Settings → Index, or run a reindex from the server."
/>
{/if}
{:else} {:else}
<div <div
data-photo-grid data-photo-grid
@@ -920,9 +1142,12 @@
}} }}
></div> ></div>
{#if photosQuery.isFetchingNextPage} {#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground"> <InlineLoader
Loading more size="sm"
</p> align="center"
polite={false}
label="Loading more photos…"
/>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -944,15 +1169,16 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading</p> <InlineLoader size="sm" label="Loading metadata…" />
{:else} {:else}
<div class="space-y-2 p-4 text-center"> <EmptyState icon={MousePointerClick} title="No photo selected">
<div class="text-xl"></div> {#snippet descriptionSnippet()}
<p class="text-xs text-muted-foreground"> <p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd
on a thumbnail to view its metadata here. >+click on a thumbnail to view its metadata here.
</p> </p>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</div> </div>
<!-- Resize handle on the left edge; mirrors the layout's left aside <!-- Resize handle on the left edge; mirrors the layout's left aside

View File

@@ -61,8 +61,8 @@
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm" class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
> >
<header class="space-y-1"> <header class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mule</h1> <h1 class="text-2xl font-semibold tracking-tight text-foreground">Mulimage</h1>
<p class="text-sm text-muted-foreground">Sign in with your PhotoPrism account.</p> <p class="text-sm text-muted-foreground">Sign in to your account.</p>
</header> </header>
<label class="block space-y-1.5"> <label class="block space-y-1.5">

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. approve). The previous section is restored on unmount.
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { import {
@@ -31,7 +30,7 @@
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
type CrossFolderScanResult type CrossFolderScanResult
} from '$lib/services/photoprism'; } 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 { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte'; import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import { import {
@@ -51,6 +50,8 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte'; import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Sparkles } from 'lucide-svelte';
type DupTab = 'stacks' | 'cross-folder'; type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab; type Tab = CauseKey | DupTab;
@@ -84,13 +85,16 @@
// observes its cache (enabled:false) and DuplicatesView is what // observes its cache (enabled:false) and DuplicatesView is what
// triggers the actual scan when its tab is active. // triggers the actual scan when its tab is active.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'], queryKey: ['duplicates', userLibraryBase()],
queryFn: listDuplicateGroups, queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(), enabled: isAuthenticated(),
staleTime: 30_000 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>(() => ({ const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: false, enabled: false,
staleTime: 5 * 60_000 staleTime: 5 * 60_000
@@ -114,7 +118,7 @@
count: g.photos.length as number | undefined count: g.photos.length as number | undefined
})), })),
{ id: 'stacks', label: 'Stacks', count: stacksCount }, { 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')); const requestedTab = $derived(page.url.searchParams.get('tab'));
@@ -136,49 +140,18 @@
}); });
const activeGroup = $derived(groups.find((g) => g.cause === activeTab)); const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
const activeTabSpec = $derived(tabs.find((t) => t.id === 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
});
}
</script> </script>
<Toolbar> <Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"> <span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review Review
</span> </span>
{#if tabs.length > 0} {#if activeTabSpec}
<div class="flex items-center gap-1"> <span class="text-[11px] font-medium">{activeTabSpec.label}</span>
{#each tabs as t (t.id)} {#if activeTabSpec.count !== undefined}
<button <span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
type="button" {/if}
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} {/if}
{#snippet trailing()} {#snippet trailing()}
<div <div
@@ -226,23 +199,27 @@
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}> <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending} {#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p> <InlineLoader label="Loading review queue…" />
{:else if reviewQuery.error} {:else if reviewQuery.error}
<p class="text-sm text-destructive"> <EmptyState
Could not load review queue: {reviewQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Could not load review queue"
description={reviewQuery.error instanceof Error
? reviewQuery.error.message ? reviewQuery.error.message
: 'unknown error'} : 'unknown error'}
</p> />
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> <EmptyState icon={Sparkles} title="Nothing to review">
<p>The review queue is empty.</p> {#snippet descriptionSnippet()}
<p class="text-xs"> <p>
PhotoPrism's indexer flags photos with a low quality score for human The indexer flags photos with a low quality score for human review. New
review. New arrivals with missing EXIF, low resolution, or unknown arrivals with missing EXIF, low resolution, or unknown cameras will land
cameras will land here. The Stacks and Cross-folder tabs above stay here. The Stacks and Duplicates tabs above stay available for
available for duplicate cleanup. duplicate cleanup.
</p> </p>
</div> {/snippet}
</EmptyState>
{:else if activeGroup} {:else if activeGroup}
{#key activeGroup.cause} {#key activeGroup.cause}
<CauseGroupCard group={activeGroup} /> <CauseGroupCard group={activeGroup} />
@@ -261,9 +238,9 @@
{#if selection.ids.size >= 2} {#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} /> <BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading metadata…" />
{/if} {/if}
</div> </div>
<div <div

View File

@@ -6,8 +6,11 @@
getPhoto, getPhoto,
listLabels, listLabels,
listPhotos, listPhotos,
listPhotosByUids,
listSubjects,
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel type PpLabel,
type PpSubject
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { import {
filtersToQ, filtersToQ,
@@ -26,12 +29,16 @@
COLOR_SWATCHES, COLOR_SWATCHES,
starLabel starLabel
} from '$lib/utils/tagGroups'; } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte'; import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte'; import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, ImageOff, MousePointerClick, Tag } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
// URL-driven category + value. `isTagCategory` rejects typos so a stray // URL-driven category + value. `isTagCategory` rejects typos so a stray
@@ -46,6 +53,16 @@
: null : null
); );
// Independent of selectedValue on purpose: the People sidebar's pinned
// "Name new faces" row sets this query param instead of the `[[value]]`
// route param, specifically so it can't be clobbered by the auto-
// select-first-tag effect (TagsBrowserSidebar) that fires whenever
// selectedValue is null — that effect is exactly what made the naming
// panel unreachable again after the first person was named.
const showNewFaces = $derived(
category === 'people' && page.url.searchParams.get('view') === 'new-faces'
);
// Mirror URL into the shared filter store so any other consumer of // Mirror URL into the shared filter store so any other consumer of
// `filters` (e.g. cross-route navigation back to `/`) sees the active // `filters` (e.g. cross-route navigation back to `/`) sees the active
// tag filter, and so `filtersToQ()` produces the correct DSL clause // tag filter, and so `filtersToQ()` produces the correct DSL clause
@@ -63,7 +80,12 @@
// filter on top would make drill counts disagree with the badges (a // filter on top would make drill counts disagree with the badges (a
// label badge of 157 could otherwise drill into 0 photos because the // label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them). // session is scoped to a folder that has none of them).
const useServer = $derived(category === 'labels' || category === 'keywords'); const useServer = $derived(
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
const drillQ = $derived( const drillQ = $derived(
useServer && selectedValue useServer && selectedValue
? filtersToQ({ ? filtersToQ({
@@ -71,6 +93,10 @@
heapUid: null, heapUid: null,
folderPath: null, folderPath: null,
search: '', search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: category, tagCategory: category,
tagValue: selectedValue tagValue: selectedValue
}) })
@@ -91,10 +117,14 @@
enabled: isAuthenticated() && useLocal, enabled: isAuthenticated() && useLocal,
staleTime: 60_000 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[]>(() => ({ const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'], queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }), queryFn: () => listPhotosByUids(markedUids),
enabled: isAuthenticated() && useLocal enabled: isAuthenticated() && useLocal && markedUids.length > 0
})); }));
const ratingGroups = $derived( const ratingGroups = $derived(
@@ -126,6 +156,11 @@
queryFn: listLabels, queryFn: listLabels,
enabled: isAuthenticated() && category === 'labels' enabled: isAuthenticated() && category === 'labels'
})); }));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const drillTitle = $derived.by(() => { const drillTitle = $derived.by(() => {
if (!selectedValue) return ''; if (!selectedValue) return '';
if (category === 'labels') { if (category === 'labels') {
@@ -135,12 +170,17 @@
return hit?.Name ?? selectedValue; return hit?.Name ?? selectedValue;
} }
if (category === 'keywords') return selectedValue; if (category === 'keywords') return selectedValue;
if (category === 'people') {
const hit = (subjectsQuery.data ?? []).find((s) => s.Slug === selectedValue);
return hit?.Name ?? selectedValue;
}
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10)); if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
if (category === 'colors') { if (category === 'colors') {
return ( return (
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
); );
} }
if (category === 'countries') return countryName(selectedValue);
return selectedValue; return selectedValue;
}); });
@@ -178,7 +218,7 @@
{#if category} {#if category}
<span class="text-[11px] capitalize text-muted-foreground">{category}</span> <span class="text-[11px] capitalize text-muted-foreground">{category}</span>
{/if} {/if}
{#if selectedValue} {#if selectedValue && !showNewFaces}
<span class="text-[11px] font-medium">{drillTitle}</span> <span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground"> <span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'} {drillCount} photo{drillCount === 1 ? '' : 's'}
@@ -186,15 +226,30 @@
{/if} {/if}
</Toolbar> </Toolbar>
{#if !selectedValue} {#if category === 'people' && (showNewFaces || !selectedValue)}
<main class="flex min-h-0 flex-1 items-center justify-center p-8"> <!-- Naming workflow: reachable both on bare landing (no person picked
<div class="max-w-sm space-y-2 text-center"> yet) and via the sidebar's pinned "Name new faces" row at any time
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p> — the latter is what makes it possible to get back here after the
<p class="text-xs text-muted-foreground"> first person's been named, once the auto-select-first-tag effect
Click a row in the panel on the left to filter the photo grid by that tag. would otherwise always jump straight to an existing person. -->
</p> <main class="min-h-0 flex-1 overflow-y-auto p-6">
<NewFacesPanel />
<div class="mt-8 flex items-center justify-center">
<EmptyState
icon={Tag}
title="Pick a person from the sidebar"
description="Click a row in the panel on the left to see that person's photos."
/>
</div> </div>
</main> </main>
{:else if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState
icon={Tag}
title={`Pick a ${category ?? 'tag'} from the sidebar`}
description="Click a row in the panel on the left to filter the photo grid by that tag."
/>
</main>
{:else} {:else}
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
@@ -205,13 +260,14 @@
{#if showSkeleton} {#if showSkeleton}
<SkeletonGrid /> <SkeletonGrid />
{:else if showError} {:else if showError}
<p class="text-sm text-destructive">Failed to load photos.</p> <EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photos" />
{:else if drillPhotos.length === 0} {:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p> <EmptyState icon={ImageOff} title="No photos under this tag" />
{:else} {:else}
<PhotoGrid photos={drillPhotos} /> <PhotoGrid photos={drillPhotos} />
{/if} {/if}
</main> </main>
<BulkActionBar />
</div> </div>
{#if !view.rightSidebarCollapsed} {#if !view.rightSidebarCollapsed}
@@ -225,15 +281,16 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading metadata…" />
{:else} {:else}
<div class="space-y-2 p-4 text-center"> <EmptyState icon={MousePointerClick} title="No photo selected">
<div class="text-xl"></div> {#snippet descriptionSnippet()}
<p class="text-xs text-muted-foreground"> <p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here. thumbnail to view its metadata here.
</p> </p>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</div> </div>
<div <div
@@ -255,5 +312,3 @@
{/if} {/if}
</div> </div>
{/if} {/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