25 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
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
58 changed files with 5295 additions and 2207 deletions

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
`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
For fast iteration on the sidecar without rebuilding its image on every

View File

@@ -2,6 +2,8 @@ package main
import (
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
@@ -85,3 +87,35 @@ func ctxBasePath(c *gin.Context) string {
}
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

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

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
)
// 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
}
// uniqueStem finds a base name (extension stripped) that is free for *every*
// extension in `exts` under destDir, appending `-1`, `-2`, … on collision —
// the multi-file analogue of uniqueName. Moving a photo's originals siblings
// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps
// PhotoPrism stacking them as one photo after reindex; picking the stem once
// for the whole group is what stops the video from being orphaned under a
// differently-suffixed name than its poster. Caps at 1000 attempts to match
// uniqueName. The passed extensions keep their on-disk case (we compare
// case-sensitively via os.Stat, which is correct on the case-sensitive
// volumes PhotoPrism targets).
func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) {
base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase))
for i := 0; i < 1000; i++ {
candidate := base
if i > 0 {
candidate = base + "-" + itoa(i)
}
free := true
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) {
free = false
break
}
}
if free {
return candidate, true
}
}
return "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
if n == 0 {
@@ -137,6 +169,7 @@ type fileEntry struct {
RelPath string
AbsPath string
Size int64
ModTime time.Time
}
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
@@ -191,6 +224,7 @@ func walkFiles(root string) ([]fileEntry, error) {
RelPath: rel,
AbsPath: p,
Size: info.Size(),
ModTime: info.ModTime(),
})
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"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const quarantineDir = ".duplicates"
@@ -20,13 +22,16 @@ const quarantineDir = ".duplicates"
type dupFileLite struct {
Path string `json:"path"`
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 {
Hash string `json:"hash"`
Size int64 `json:"size"`
IndexedPath *string `json:"indexedPath"`
Files []dupFileLite `json:"files"`
Hash string `json:"hash"`
Size int64 `json:"size"`
IndexedPath *string `json:"indexedPath"`
Files []dupFileLite `json:"files"`
}
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
@@ -36,17 +41,41 @@ type dupListPhoto struct {
Files []ppFile `json:"Files"`
}
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
start := time.Now()
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
all, err := walkFiles(cfg.OriginalsRoot)
// Scope the walk to the user's effective library root (BasePath +
// chosen index sub-path), same as the folders/timeline/reindex scope —
// otherwise a narrowed root would still surface every other user's
// files in the cross-folder duplicate scan. "" means whole library
// (today's admin-without-BasePath default).
root := effectiveLibraryRoot(c, db)
scanRoot := cfg.OriginalsRoot
if root != "" {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"})
return
}
scanRoot = abs
}
slog.Info("dup.scan starting", "root", scanRoot)
all, err := walkFiles(scanRoot)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// walkFiles computes RelPath relative to scanRoot; re-prefix with the
// scoped sub-path so RelPath stays originals-root-relative, matching
// what handleDupArchive (and the rest of the API) expects.
if root != "" {
for i := range all {
all[i].RelPath = root + "/" + all[i].RelPath
}
}
// Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip
@@ -102,7 +131,11 @@ func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
}
g := dupGroup{Hash: h, Size: hashSize[h]}
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
// PhotoPrism's side (indexed column).
@@ -149,7 +182,7 @@ type dupArchiveErr struct {
Error string `json:"error"`
}
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupArchiveBody
@@ -158,6 +191,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return
}
// Authz: every path must live under the caller's effective library
// root. The scan above already only ever returns paths from there,
// but this endpoint takes paths straight from the request body, so a
// scoped (non-admin, or admin-with-sub-path) user could otherwise
// pass an arbitrary originals-relative path and archive (move) files
// outside their own folder.
root := effectiveLibraryRoot(c, db)
if root != "" {
for _, p := range body.Paths {
clean := strings.Trim(p, "/")
if clean != root && !strings.HasPrefix(clean, root+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
return
}
}
}
// Each archive batch lands in its own timestamped subdir so the
// user can browse what was quarantined when (and recover by hand
// if they change their mind).
@@ -228,3 +278,95 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
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"})
return
}
if !requireUserScope(c, cfg, abs, true) {
return
}
if _, err := os.Stat(abs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
return
@@ -92,6 +95,9 @@ func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
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"})
@@ -142,6 +148,9 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
return
}
if !requireUserScope(c, cfg, abs, true) {
return
}
st, err := os.Stat(abs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})

View File

@@ -67,4 +67,4 @@ func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"folders": filtered})
}
}
}

View File

@@ -90,9 +90,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
// Pull the heap's photos via the q=album:UID query. count=1000 covers
// every realistic heap; merged=true expands stacked variants so we
// move the JPG/HEIC sibling alongside the primary.
if !requireUserScope(c, cfg, targetAbs, false) {
return
}
// Pull the heap's membership via the q=album:UID query (count=1000
// covers every realistic heap). We only need the UID list here — the
// search's Files array is trimmed and drops videos, so we re-resolve
// each photo's full file set below via resolvePhotosFull.
q := url.QueryEscape("album:" + albumUID)
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
@@ -104,17 +108,31 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
var listed []heapPhoto
if err := json.Unmarshal(resp.Body, &listed); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
uids := make([]string, 0, len(listed))
for _, p := range listed {
uids = append(uids, p.UID)
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
// Re-fetch each photo's complete file list so videos (and other multi-
// file photos) move whole — the album search alone would orphan the
// .mov. See resolvePhotosFull.
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, uids)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
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
}
errs = append(resolveErrs, errs...)
heapDeleted := false
if deleteHeap {
@@ -139,6 +157,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"movedFiles": movedPairs,
"errors": errs,
"heap_deleted": heapDeleted,
})
@@ -152,90 +171,149 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
// but move them identically. Returns per-photo errors in `errs`; the returned
// top-level error is only for a fatal precondition (subfolder mkdir failed).
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
// `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, e
return 0, 0, nil, nil, e
}
}
sourceParents := map[string]struct{}{}
errs = []heapErr{}
movedPairs = []dupMoved{}
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
// 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 == "/" && f.Primary {
file, found = f, true
break
if f.Root != "/" {
continue
}
group = append(group, f)
if f.Primary && !havePrimary {
primary, havePrimary = f, true
}
}
if !found {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
if len(group) == 0 {
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
if !havePrimary {
primary = group[0]
}
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
// 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)
}
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
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 {
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.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
}
// 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
}
moved++
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
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++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
// Reindex the destination + every source parent so PhotoPrism's DB
@@ -261,7 +339,7 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
fireReindex(cfg, pp, token, reindex)
}
return moved, copied, errs, nil
return moved, copied, movedPairs, errs, nil
}
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."

View File

@@ -115,14 +115,14 @@ func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
// 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"`
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"`
}
@@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusOK, counts)
}
}
}

View File

@@ -1,13 +1,13 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
@@ -56,33 +56,28 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
if !requireUserScope(c, cfg, targetAbs, false) {
return
}
// Resolve the photos via a single q=uid:a|b|c query. PhotoPrism's
// search treats `|` as OR within a filter value, so one round-trip
// covers the whole selection; merged=true pulls stacked variants so
// the JPG/HEIC sibling travels with its primary.
q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|"))
listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
// 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
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
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),
@@ -92,13 +87,44 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
"errors", len(errs),
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
"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"`
@@ -126,6 +152,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
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"})
@@ -136,6 +165,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
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"})
@@ -172,3 +204,96 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
})
}
}
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})
}
}

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"})
return
}
if !requireUserScope(c, cfg, oldAbs, false) {
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.Mode().IsRegular() {
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})

View File

@@ -79,45 +79,59 @@ func main() {
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
}
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
// User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp))
// 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))
}
// 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 photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(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))
}
// 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)
srv := &http.Server{
@@ -150,4 +164,3 @@ func main() {
}
<-idleClosed
}

View File

@@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
type ppSessionUser struct {
UserUID string `json:"UID"`
UserName string `json:"Name"`
Role string `json:"Role"`
BasePath string `json:"BasePath"`
}

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.
}
// 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
SET base_path = ?
SET base_path = ?, upload_path = ?
WHERE user_name = ?
AND COALESCE(base_path, '') <> ?
AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
AND deleted_at IS NULL`,
path, username, path)
path, path, username, path, path)
if res.Error != nil {
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
continue

247
web/package-lock.json generated
View File

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

View File

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

View File

@@ -7,10 +7,13 @@ import {
batchArchive,
batchDelete,
batchRestore,
bulkSetMarks,
removeFromHeap,
type PhotoMark,
type PhotoMarksMap,
type PpAlbum
} from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
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';
@@ -34,10 +37,16 @@ import {
removedBulk,
failBulk,
setDetail,
markRemoved,
clearRemoved
markRemoved
} from '$lib/stores/bulkAction.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
import {
closeShortcuts,
openPreview,
toggleLeftSidebar,
toggleRightSidebar,
toggleShortcuts,
view
} from '$lib/stores/view.svelte';
/**
* Optional parameters the host passes via `use:gridKeyNav={...}`.
@@ -69,8 +78,8 @@ export interface GridKeyNavParams {
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
* ⌘A selects all visible.
* Rating + color labels are mouse-driven via the metadata sidebar — no
* keyboard shortcuts.
* 05 rating, 69 Lightroom color labels, / focuses search,
* ? opens the shortcut reference overlay.
*
* Archive / restore target a synthesized "cull target list" — in priority:
* 1. multi-selection set
@@ -207,17 +216,19 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (target) {
// Destructive removal: flash a red cross, then pull the tiles out of
// the grid immediately (markRemoved) rather than waiting on the slow
// server-reconcile refetch. clearRemoved once the refetch settles so
// the archived-filtered page replaces the optimistic hide.
// server-reconcile refetch. The grid reconciles `removedIds` against
// the cache and drops each id once the archived-filtered page has
// actually replaced it (see +page.svelte), so we don't clear here —
// clearing on this action's own settle raced other in-flight archives
// and flashed photos back in.
removedBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] });
void settled.then(() => clearRemoved(ids));
} else {
doneBulk(doneLabel, ids);
focusAfter(ids);
@@ -267,9 +278,10 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
// 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'] });
void settled.then(() => clearRemoved(ids));
toast.success(`Deleted ${ids.length}`, { id: tid });
}
@@ -375,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
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() {
if (filters.section !== 'heap' || !filters.heapUid) {
toast.message('Press S then 19 to pick a heap');
@@ -394,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
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
// 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
@@ -429,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey;
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.
// Matches the dblclick gesture so the user has both keyboard and
// mouse paths to the same surface. `e.code === 'Space'` covers
@@ -475,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
clearSelection();
setFocused(null);
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':
// Tab in the grid context = mule-image's left-sidebar toggle.
// Browsers reserve Tab for focus traversal — preventDefault
@@ -554,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault();
void toggleArchive('restore');
return;
case 'f':
case 'F':
if (meta || shift) return;
e.preventDefault();
void toggleFavorite(cullTargets());
return;
case 'm':
case 'M': {
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
copy of the same byte-identical file. The user picks one to keep; the
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
One cross-folder duplicate group. Every copy is byte-identical (same
sha1, same thumbnail) so the old N-identical-thumbnails grid told the
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
a single Photo stack):
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
index time). They're files on disk only.
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
can render every copy from the same hash even though only one Photo
entry exists.
- Resolution moves files (reversible) rather than deletes (irreversible).
Resolution moves files (reversible, quarantine + undo) rather than
deletes — logic lives in services/duplicateActions.svelte.ts.
Same keyboard contract as StackGroupCard: arrows pick the keeper,
Enter commits.
Keyboard (↑/↓/j/k bubble to DuplicatesView's group navigation):
- ←/→ or 19 move the keeper pick.
- Enter archives every other copy.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
archiveDuplicatePaths,
type CrossFolderDuplicateGroup
} from '$lib/services/photoprism';
import { resolveCrossFolder } from '$lib/services/duplicateActions.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 {
group: CrossFolderDuplicateGroup;
/** First-card auto-focus, same pattern as StackGroupCard. */
autoFocus?: boolean;
focused?: 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 busy = $state(false);
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
// Seed `keep` from the indexed path when available; that's the safest
// default because losing it would leave PhotoPrism with no copy. Fall
@@ -48,38 +38,15 @@
keep =
group.indexedPath && validPaths.has(group.indexedPath)
? group.indexedPath
: group.files[0]?.path ?? '';
: (group.files[0]?.path ?? '');
}
});
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Column-count tracking — identical pattern to StackGroupCard.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
if (focused && sectionEl) {
sectionEl.focus({ preventScroll: true });
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
function sizeLabel(bytes: number): string {
@@ -93,6 +60,38 @@
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) {
const i = group.files.findIndex((f) => f.path === keep);
if (i < 0) return;
@@ -102,71 +101,40 @@
function onKeydown(e: KeyboardEvent) {
if (busy) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
moveKeep(-1);
return;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
moveKeep(1);
return;
case 'ArrowUp':
e.preventDefault();
moveKeep(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveKeep(cols);
return;
case 'Enter':
e.preventDefault();
e.stopPropagation();
void commit();
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
default: {
const n = Number.parseInt(e.key, 10);
if (n >= 1 && n <= group.files.length) {
e.preventDefault();
e.stopPropagation();
keep = group.files[n - 1].path;
}
}
}
}
async function commit() {
if (busy || group.files.length < 2) return;
// Defensive guard: never archive the indexed copy. The user can
// pick a different "keeper" but the archive list is computed AFTER
// resolving that into "everything except the keeper". If they pick
// a non-indexed copy as keeper, the indexed one gets archived —
// PhotoPrism will lose its photo entry on the cleanup reindex.
// That's a legitimate user choice (they wanted to move the
// canonical copy), just call it out in the toast.
const losers = group.files.filter((f) => f.path !== keep);
if (losers.length === 0) return;
const losingIndexed =
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
busy = true;
try {
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
if (result.errors.length > 0) {
toast.error(
`Archived ${result.moved.length}; ${result.errors.length} failed`,
{
description: result.errors[0].error
}
);
} else {
toast.success(
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
{
description: losingIndexed
? 'The previously-indexed copy was moved; the indexer 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');
const ok = await resolveCrossFolder(group, keep);
if (ok) onResolved?.();
} finally {
busy = false;
}
@@ -179,87 +147,101 @@
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Duplicate group · ${group.files.length} copies`}
aria-label={`Duplicate group of ${group.files.length} copies — ←/→ pick which path to keep, Enter archives the rest`}
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"
onfocusin={() => onFocusRequest?.()}
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">
<div class="min-w-0">
<!-- Single thumbnail — every copy is byte-identical, so N tiles of the
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">
{group.files.length} copies · {sizeLabel(group.size)} each
</div>
<div class="truncate text-[10px] font-mono text-muted-foreground">
sha1 {group.hash.slice(0, 16)}
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Move the unselected copies to .duplicates/ (reversible)"
>
Keep selected, archive rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</header>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.path)}
{@const isKeep = file.path === keep}
{@const isIndexed = file.path === group.indexedPath}
<button
type="button"
onclick={() => (keep = file.path)}
class:scale-95={isKeep}
class:ring-2={isKeep}
class:ring-blue-500={isKeep}
class:ring-offset-2={isKeep}
class:ring-offset-background={isKeep}
class:transition-[transform,box-shadow]={isKeep}
class:duration-300={isKeep}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
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/ (recoverable)"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumbUrl(group.hash, 'tile_500')}
alt={file.path}
loading="lazy"
class="h-full w-full object-cover"
/>
{#if isKeep}
<span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
Keep
</span>
{/if}
{#if isIndexed}
<span
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Currently in the library"
>
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}
Keep selected path
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
</div>
</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>

View File

@@ -1,24 +1,26 @@
<!--
Duplicate-resolution page body. Two panels driven by the parent
route's `activeTab` prop (URL-bound):
Duplicate-resolution queue. Two panels driven by the parent route's
`activeTab` prop (URL-bound):
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
`stack:true` and resolve via `setPrimary` + `deleteFile`.
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; listed via
`stack:true`, resolved via resolveStack() (setPrimary + quarantine).
2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism
never adds those rows to its DB, so we scan the filesystem via the
mule-sidecar. Resolution moves the unwanted copies into a
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
because they were byte-identical to an existing entry. Scanned via
the sidecar's filesystem walk, resolved via resolveCrossFolder()
(quarantine).
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
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
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.
long staleTime keeps tab bounces from re-running it.
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -28,10 +30,20 @@
type CrossFolderScanResult
} from '$lib/services/photoprism';
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 CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
import { AlertCircle, CheckCircle2, Copy, HardDrive } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder';
@@ -50,110 +62,242 @@
// "Rescan filesystem" button invalidates to force a re-scan after
// the user has moved files around.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates,
enabled: activeTab === 'cross-folder',
staleTime: 5 * 60_000
}));
function rescan() {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] });
}
$effect(() => {
if (crossQuery.error) {
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Duplicates scan failed'
crossQuery.error instanceof Error ? crossQuery.error.message : 'Duplicates 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>
<!-- Stacks tab ----------------------------------------------------- -->
{#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 groups.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 groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
<!-- Sticky progress header — shared by both tabs -->
<div
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"
>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span class="font-medium text-foreground">
{activeGroups.length}
{activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'}
</span>
{#if reclaimableBytes > 0}
<span class="flex items-center gap-1">
<HardDrive class="h-3 w-3" />
{formatBytes(reclaimableBytes)} reclaimable
</span>
{/if}
{#if dupSession.resolved > 0}
<span class="text-emerald-500">
Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session
</span>
{/if}
</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>
<!-- 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">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files the indexer dropped at index time. Found by scanning the
originals tree directly.
</p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={rescan}
>
{#if crossQuery.isFetching}
Scanning…
{:else}
Rescan filesystem
{/if}
</button>
</header>
{#if crossQuery.isFetching && !crossQuery.data}
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onkeydown={onQueueKeydown}>
<!-- Stacks tab ----------------------------------------------------- -->
{#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>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
{/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">
Byte-identical files the indexer dropped at index time. Found by scanning the originals
tree directly.
</p>
{#if crossQuery.isFetching && !crossQuery.data}
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#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}
</div>
{/if}
</div>

View File

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

@@ -41,7 +41,7 @@
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -69,6 +69,10 @@
* "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */
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 {
nodes,
@@ -80,7 +84,8 @@
onMove,
readonly = false,
selectedPath,
counts
counts,
forceExpand = false
}: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state
@@ -145,7 +150,7 @@
<ul>
{#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 hasChildren = node.children.length > 0}
<li>
@@ -164,30 +169,38 @@
>
{#if hasChildren}
<button
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
class:text-muted-foreground={!active}
onclick={() => toggle(node.path)}
title={open ? 'Collapse' : 'Expand'}
aria-label={open ? 'Collapse' : 'Expand'}
>
{open ? '▾' : '▸'}
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
/>
</button>
{:else}
<!-- Spacer keeps childless siblings aligned with their chevroned
peers at every depth, so labels share a common left edge
across the sidebar (folders, heaps, views, manage). -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
{/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge was previously a dead
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
class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(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>
{#if counts && counts[node.path] !== undefined}
@@ -254,6 +267,7 @@
{readonly}
{selectedPath}
{counts}
{forceExpand}
/>
{/if}
</li>

View File

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

View File

@@ -12,6 +12,7 @@
deleteFolder,
deleteHeap,
duplicateHeap,
getIndexSubpath,
heapDownloadUrl,
listFolders,
listHeaps,
@@ -19,6 +20,7 @@
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
startIndex,
triggerDownload,
type CrossFolderScanResult,
type PpAlbum,
@@ -42,14 +44,25 @@
type Section,
type TagCategory
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import {
isAuthenticated,
prefs,
session,
setIndexSubpathState,
userBasePath,
userLibraryBase,
toOriginalsPath,
toUserPath
} from '$lib/stores/session.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import { indexer } from '$lib/stores/indexer.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import {
ChevronRight,
Copy,
Download,
FolderInput,
@@ -59,6 +72,7 @@
LogOut,
Moon,
Pencil,
RefreshCw,
Settings,
Sun,
Trash2,
@@ -74,25 +88,48 @@
enabled: isAuthenticated()
}));
// Keyed on the effective library base (BasePath + chosen index sub-path)
// so re-rooting refetches, and so the post-bootstrap identity change forces
// a fresh fetch instead of leaving the query wedged in pending/idle (the
// old `gcTime: 0` + `enabled` toggle could strand it there on first paint).
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryKey: ['folders', userLibraryBase()],
queryFn: listFolders,
enabled: isAuthenticated(),
gcTime: 0
staleTime: 30_000,
retry: 2,
refetchOnMount: 'always'
}));
// Hydrate the per-user index sub-path into the session store on load so the
// Library tree re-roots to it without waiting for the settings dialog to be
// opened. Shares the ['prefs'] key with SettingsDialog's setter.
const prefsQuery = createQuery<string>(() => ({
queryKey: ['prefs'],
queryFn: getIndexSubpath,
enabled: isAuthenticated(),
staleTime: 5 * 60_000
}));
$effect(() => {
if (prefsQuery.data !== undefined) setIndexSubpathState(prefsQuery.data);
});
// Stacks + cross-folder duplicate caches are warmed here so the
// /duplicates view (and its review tab strip) hits a warm cache. The
// sidebar only observes these — cross-folder is an O(disk) scan, so it
// stays enabled:false and the duplicates page populates it on first visit.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
queryKey: ['duplicates', userLibraryBase()],
queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(),
staleTime: 60_000
}));
// The cross-folder scan is server-scoped to the caller's effective
// library root (sidecar reads BasePath + the stored index sub-path
// itself), but the query is still keyed on userLibraryBase() so changing
// the index folder invalidates the stale, differently-scoped result.
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000
@@ -168,45 +205,13 @@
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
}
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
// above (keeping it out of `view.metadataSections`, which is reserved for
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
// doesn't grow on first paint.
const TAGS_OPEN_KEY = 'mule_tags_expanded';
let tagsExpanded = $state(loadTagsExpanded());
function loadTagsExpanded(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(TAGS_OPEN_KEY);
return raw === '1';
}
function toggleTags() {
tagsExpanded = !tagsExpanded;
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
}
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
// row in Manage can expose the same set of tabs the /review page shows
// (cause groups + duplicates panels). Defaults to collapsed.
const REVIEW_OPEN_KEY = 'mule_review_expanded';
let reviewExpanded = $state(loadReviewExpanded());
function loadReviewExpanded(): boolean {
if (!browser) return false;
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
}
function toggleReview() {
reviewExpanded = !reviewExpanded;
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
}
// Cause-tab list is dynamic (only buckets with hits show up on /review),
// so the sidebar mirrors that by reusing the same query. Gated on
// `reviewExpanded` to avoid paying the /photos round-trip for users who
// never expand the section; the queryKey is shared with the /review page
// so visiting that route warms the cache for free.
// so the sidebar mirrors that by reusing the same query. The queryKey is
// shared with the /review page so visiting that route warms the cache for free.
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'],
queryFn: listReviewGroups,
enabled: isAuthenticated() && reviewExpanded,
enabled: isAuthenticated(),
staleTime: 30_000
}));
@@ -236,7 +241,8 @@
keywords: 'Keywords',
people: 'People',
colors: 'Colors',
ratings: 'Ratings'
ratings: 'Ratings',
countries: 'Countries'
};
function isTagCategoryActive(cat: TagCategory): boolean {
@@ -265,6 +271,17 @@
session.user?.DisplayName?.trim() || session.user?.Name || '/'
);
// When the user has narrowed their library to an index sub-folder, the
// root row stands for that sub-folder — surface its leaf name so it's
// obvious the tree is re-rooted rather than showing the whole account.
const rootSubLabel = $derived(
prefs.indexSubpath === '' ? '' : (prefs.indexSubpath.split('/').pop() ?? '')
);
const rootTitle = $derived.by(() => {
const base = userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`;
return prefs.indexSubpath === '' ? base : `${base}${prefs.indexSubpath}`;
});
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
@@ -281,10 +298,15 @@
}
const createFolderMut = createMutation(() => ({
mutationFn: (relPath: string) => createFolder(relPath),
// The sidebar deals in user-relative paths (BasePath stripped); the
// sidecar operates on originals-relative paths. Translate on the way
// out (toOriginalsPath) and back for display (toUserPath), exactly like
// the move flow — otherwise a BasePath user's folder ops resolve to the
// wrong directory and the sidecar returns "invalid path".
mutationFn: (relPath: string) => createFolder(toOriginalsPath(relPath)),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`);
toast.success(`Folder created: ${toUserPath(r.path)}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder')
@@ -292,36 +314,59 @@
const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName),
renameFolder(toOriginalsPath(args.rel), args.newName),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
// Handler returns originals-relative paths; map back to the UI's
// user-relative space before comparing/navigating.
const oldUi = toUserPath(r.oldPath);
const newUi = toUserPath(r.newPath);
// If the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) {
setFolderPath(r.newPath);
const params = new URLSearchParams({ folder: r.newPath });
if (filters.folderPath === oldUi) {
setFolderPath(newUi);
const params = new URLSearchParams({ folder: newUi });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
toast.success(`Renamed: ${r.oldPath}${r.newPath}`);
toast.success(`Renamed: ${oldUi}${newUi}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed')
}));
const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel),
mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
const ui = toUserPath(r.path);
if (filters.folderPath && filters.folderPath.startsWith(ui)) {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}
toast.success(`Folder deleted: ${r.path}`);
toast.success(`Folder deleted: ${ui}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Delete failed')
}));
// One-click "reindex new files": kicks off a scan of the whole library
// with rescan off, so PhotoPrism only picks up files it hasn't indexed
// yet. Progress streams in via the WebSocket indexer pill, and the grid
// auto-refreshes as new tiles land (see indexer store). Guarded against
// double-trigger while a scan is already running.
async function onReindex() {
if (indexer.active) return;
const tid = toast.loading('Starting reindex…');
try {
// Scope the one-click reindex to the effective library root
// (BasePath + chosen index sub-path) rather than the whole library.
await startIndex({ path: '/' + toOriginalsPath('/'), rescan: false, cleanup: false });
toast.success('Reindex started — new files will appear as theyre found', { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
}
}
function onCreateFolder(parent: string | null = null) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return;
@@ -377,7 +422,7 @@
//
// `getCount` is a getter (not a snapshot) so the badge reads the latest
// derived value on every render — the arrays themselves are constant.
// Map and Tags intentionally render without a count badge; the count
// Tags intentionally renders without a count badge; the count
// columns inside the TagsBrowserSidebar are the canonical surface for
// per-tag totals. Review rolls in the duplicates tabs hosted under
// /review — stacks always contributes; cross-folder only contributes
@@ -392,10 +437,9 @@
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
// Ratings/Countries) and a chevron, neither of which fits the flat
// section/route ViewItem shape. Notes lives under that expandable
// alongside the tag categories.
];
@@ -484,6 +528,17 @@
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</span>
<button
class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
class:opacity-0={!indexer.active}
class:opacity-100={indexer.active}
onclick={onReindex}
disabled={indexer.active}
title="Reindex new files"
aria-label="Reindex new files"
>
<RefreshCw class="h-3 w-3 {indexer.active ? 'animate-spin' : ''}" />
</button>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
@@ -518,26 +573,33 @@
{#if hasSubfolders}
<button
type="button"
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
class:text-muted-foreground={!rootActive}
onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
>
{rootExpanded ? '▾' : '▸'}
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
/>
</button>
{:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned
peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
{/if}
<button
type="button"
class="flex min-w-0 flex-1 items-center pl-1 text-left"
class="flex min-w-0 flex-1 items-center gap-1 pl-1 text-left"
onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
title={rootTitle}
>
<span class="truncate">{rootLabel}</span>
{#if rootSubLabel}
<span class="truncate text-muted-foreground" class:text-primary-foreground={rootActive}>
/ {rootSubLabel}
</span>
{/if}
</button>
<!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted
@@ -555,7 +617,7 @@
</KebabMenu>
</div>
</div>
{#if foldersQuery.isPending}
{#if foldersQuery.isLoading}
<InlineLoader size="sm" label="Loading folders…" />
{:else if foldersQuery.isError}
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />
@@ -683,63 +745,34 @@
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
<!--
Tags expandable. Whole row is a toggle (chevron + label); there is
no landing page at /tags — selecting a sub-category is the only way
into a real view. Counts intentionally live in the TagsBrowserSidebar
(secondary sidebar) so this row stays a pure navigator.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleTags}
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
aria-expanded={tagsExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
>
{tagsExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
</span>
</button>
{#if tagsExpanded}
<!--
Notes lives alongside the tag categories — same indent and row
chrome — but routes to /notes rather than /tags/*. Tucked at
the top of the expandable so it's the first thing the user
sees when opening Tags.
-->
<!-- Notes -->
{#if true}
{@const notesActive = isNotesActive()}
<a
href="/notes"
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={notesActive}
class:text-primary-foreground={notesActive}
class:hover:bg-primary={notesActive}
style="padding-left: 36px;"
>
<span class="truncate">Notes</span>
</a>
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(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>
</a>
{/each}
{/if}
<!-- Tag categories -->
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
</a>
{/each}
</div>
<!-- Manage — curation flows that decide a photo's fate. Same
@@ -751,57 +784,28 @@
Manage
</span>
</div>
<!--
Review expandable. Mirrors the Tags affordance — pure toggle
with no landing page; the only way into a tab is to expand and
pick a subitem. Cause buckets are dynamic (only buckets with
hits show up); Stacks/Cross-folder are always present.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleReview}
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
aria-expanded={reviewExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
<!-- 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}
>
{reviewExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Review</span>
</span>
</button>
{#if reviewExpanded}
{#each reviewTabs as t (t.id)}
{@const active = isReviewTabActive(t.id)}
<a
href={`/review?tab=${t.id}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
>
<span class="truncate">{t.label}</span>
</a>
{/each}
<!--
Hidden lives under Review since it's the resting place for
photos dismissed during review. Section-nav (not a ?tab=),
so it's a button that flips filters.section like the flat
Manage entries — just with the subitem indent.
-->
<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 pr-2 text-left text-[12px] leading-tight hover:bg-accent"
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}
style="padding-left: 36px;"
onclick={() => navigateTo('hidden')}
>
<span class="truncate">Hidden</span>

View File

@@ -14,26 +14,36 @@
folder keeps its own name. The picker excludes the folder
itself and its descendants.
Picker reuses the readonly FolderTree; the dialog owns the selection
(`pickedPath`) so it never fights the global folderPath filter.
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, Loader2 } from 'lucide-svelte';
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 } from '$lib/stores/session.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();
@@ -41,7 +51,7 @@
// 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'],
queryKey: ['folders', userLibraryBase()],
queryFn: listFolders,
enabled: isAuthenticated()
}));
@@ -50,16 +60,74 @@
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 folderTree = $derived.by(() => {
const basePaths = $derived.by(() => {
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
if (subject?.kind === 'folder') {
const self = subject.path;
return buildTree(paths.filter((p) => p !== self && !p.startsWith(self + '/')));
return paths.filter((p) => p !== self && !p.startsWith(self + '/'));
}
return buildTree(paths);
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');
@@ -67,6 +135,11 @@
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';
@@ -75,25 +148,72 @@
});
const headerDesc = $derived.by(() => {
if (subject?.kind === 'heap') {
const n = subject.heap.PhotoCount ?? 0;
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`;
const n = photoCount;
return `${subject?.kind === 'heap' ? (subject.heap.Title ?? '') : ''} · ${n} photo${n === 1 ? '' : 's'}`;
}
if (subject?.kind === 'photos') {
const n = subject.uids.length;
return `${n} photo${n === 1 ? '' : 's'} selected`;
return `${photoCount} photo${photoCount === 1 ? '' : 's'} selected`;
}
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
return '';
});
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
let submitting = $state(false);
// ── 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.
// 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;
@@ -101,6 +221,11 @@
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
@@ -109,30 +234,135 @@
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) return;
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(pickedPath),
mode,
subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap
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(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
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');
@@ -141,32 +371,60 @@
} else if (s.kind === 'photos') {
const r = await movePhotosToFolder({
uids: s.uids,
targetFolder: toOriginalsPath(pickedPath),
mode,
subfolder: subfolder.trim() || null
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(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
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.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(pickedPath));
const r = await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = pickedPath === '' ? folderName : `${pickedPath}/${folderName}`;
toast.success(`Moved ${folderName}${pickedPath === '' ? '/' : pickedPath}`);
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);
}
closeMove();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed');
} finally {
submitting = false;
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
}
}
</script>
@@ -182,112 +440,179 @@
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"
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"
>
<div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
{headerTitle}
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
{headerDesc}
</Dialog.Description>
</div>
</div>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{kind === 'folder' ? 'Destination parent' : 'Destination'}
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: drop straight into originals/ (the user's root)
without picking a subfolder. Empty string is the
sidecar's "root" sentinel. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
that keeps the folder's own name). -->
{#if showOptions}
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
<!-- 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>
<label class="flex flex-col gap-1 text-[12px]">
<span class="text-muted-foreground">New subfolder (optional)</span>
<input
type="text"
placeholder="e.g. 2024-summer"
bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
{#if showDeleteHeap}
<label class="flex items-center gap-1.5 text-[12px]">
<input type="checkbox" bind:checked={deleteHeap} disabled={mode === 'copy'} />
<span class:text-muted-foreground={mode === 'copy'}>Delete heap after move</span>
</label>
{/if}
</div>
{/if}
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={closeMove}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={pickedPath === null || submitting}
>
{#if submitting}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'}
</button>
<!-- 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>

View File

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

View File

@@ -0,0 +1,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,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

@@ -16,6 +16,7 @@
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
@@ -101,6 +102,14 @@
setFocused(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>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
@@ -145,30 +154,54 @@
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={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, 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
<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}
</div>

View File

@@ -82,11 +82,13 @@
() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
// Per-photo patch so each photo keeps its own UTC↔local
// offset when the date is stamped across a selection.
(p) => buildTakenAtPatch(iso, p),
label,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
? buildTakenAtPatch(p.TakenAt, p)
: ({ TakenSrc: '' } as UpdatePhotoBody)
),
label

View File

@@ -6,7 +6,6 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
@@ -14,16 +13,19 @@
Aperture,
ArrowUpRight,
Calendar,
Copy,
File,
Folder,
Globe,
HardDrive,
Heart,
ImageIcon,
Loader2,
Map as MapIcon,
MapPin,
Star,
Tag,
Timer,
User,
X
} from 'lucide-svelte';
import {
@@ -38,13 +40,22 @@
type UpdatePhotoBody
} 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 { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder } from '$lib/stores/filters.svelte';
import {
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 {
@@ -55,14 +66,19 @@
const qc = useQueryClient();
let basename = $state('');
let title = $state('');
let caption = $state('');
let takenAt = $state('');
let lat = $state('');
let lng = $state('');
let altitude = $state('');
let country = $state('');
let keywords = $state<string[]>([]);
let keywordDraft = $state('');
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
* prefix and basename. Sidecar's rename endpoint only accepts a bare
@@ -76,16 +92,21 @@
$effect(() => {
const pf = primaryFile(photo);
basename = splitName(pf.Name ?? '').base;
title = photo.Title ?? '';
caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : '';
altitude = photo.Altitude ? String(photo.Altitude) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {};
keywords = (det.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
});
const patchMutation = createMutation(() => ({
@@ -141,6 +162,11 @@
if (caption === (photo.Caption ?? '')) return;
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));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the
@@ -181,14 +207,16 @@
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso));
commit(buildTakenAtPatch(iso, photo));
}
function commitGps() {
const nlat = parseFloat(lat);
const nlng = parseFloat(lng);
const nalt = parseFloat(altitude);
const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
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);
}
function commitCountry() {
@@ -198,7 +226,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' });
}
type DetailsKey = 'Keywords';
type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return;
@@ -279,7 +307,42 @@
const currentRating = $derived(photoMark.rating ?? 0);
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));
// 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 folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
@@ -307,6 +370,36 @@
const joined = `${make} ${model}`.trim();
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 } {
return {
iso: p.Iso ? `ISO ${p.Iso}` : '',
@@ -432,28 +525,22 @@
</span>
</div>
<!-- Location (read-only label + open-on-map icon). The arrow-up-
right icon flies the in-app map to the photo's coordinates at
zoom 17 (close enough for the photo's marker to be its own,
out of any cluster). Hidden when the photo has no
coordinates. -->
<!-- Location (read-only label + jump-to-country icon). Hidden when
the photo has no resolved country. -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if photo.Lat && photo.Lng}
{#if photo.Country && photo.Country !== 'zz'}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
void goto(
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
)}
title="Open on map"
aria-label="Open on map"
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
title={`View other photos from ${countryName(photo.Country)}`}
aria-label={`View other photos from ${countryName(photo.Country)}`}
>
<MapIcon class="h-3 w-3" />
<Globe class="h-3 w-3" />
</button>
{/if}
</div>
@@ -480,6 +567,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">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
@@ -507,6 +605,19 @@
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button>
{/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>
@@ -563,6 +674,27 @@
</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-
only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them
@@ -637,6 +769,62 @@
onblur={commitCountry}
/>
</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>
</details>
@@ -647,20 +835,51 @@
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
>
<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">
<ImageIcon class="h-3 w-3" /> File
</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>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
{#if cameraStr}
<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 lensStr && lensStr !== cameraStr}
<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 exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
<dt class="text-muted-foreground">Exposure</dt>
@@ -681,6 +900,18 @@
{/if}
<dt class="text-muted-foreground">Type</dt>
<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>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt>

View File

@@ -1,15 +1,21 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import {
aggregateKeywords,
getAllMarks,
listCountries,
listLabels,
listPhotosByUids,
listSubjects,
listUnnamedFaces,
type AggregatedKeyword,
type PhotoMarksMap,
type PpCountry,
type PpLabel,
type PpSubject
type PpSubject,
type UnnamedFaceCluster
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
@@ -20,9 +26,10 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag, User } from 'lucide-svelte';
import { Globe, Hash, Tag, User, UserPlus } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -31,6 +38,29 @@
}
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('');
// Reset the inline filter input whenever the user switches categories so
@@ -64,6 +94,12 @@
enabled: isAuthenticated() && category === 'people'
}));
const countriesQuery = createQuery<PpCountry[]>(() => ({
queryKey: ['countries'],
queryFn: listCountries,
enabled: isAuthenticated() && category === 'countries'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
@@ -125,6 +161,19 @@
);
});
// PhotoPrism returns countries unsorted; sort by photo count descending so
// the most-photographed countries surface first (mirrors labels/people).
const countriesSorted = $derived(
[...(countriesQuery.data ?? [])].sort((a, b) => b.PhotoCount - a.PhotoCount)
);
const filteredCountries = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return countriesSorted;
return countriesSorted.filter((c) =>
countryName(c.Code).toLowerCase().includes(q)
);
});
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
@@ -152,9 +201,11 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const visibleCountries = $derived(filteredCountries.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
function loadMore() {
visibleCount += PAGE_SIZE;
@@ -179,6 +230,9 @@
const s = String(r);
if (selectedValue !== s) onSelect(s);
}
function pickCountry(code: string) {
if (selectedValue !== code) onSelect(code);
}
// First non-empty entry for the active category. Labels/keywords are
// already sorted by count desc, so [0] is the most-used tag; colors
@@ -203,6 +257,9 @@
const g = ratingGroups[0];
return g ? String(g.rating) : null;
}
if (category === 'countries') {
return countriesSorted[0]?.Code ?? null;
}
return null;
});
@@ -213,8 +270,16 @@
// fires when there's genuinely no selection — once a value is picked
// (by the user or by this effect), the URL drives selectedValue and
// 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(() => {
if (selectedValue != null) return;
if (newFacesActive) return;
if (firstValue == null) return;
onSelect(firstValue, { replace: true });
});
@@ -228,11 +293,16 @@
? 'People'
: category === 'colors'
? 'Colors'
: 'Ratings'
: category === 'countries'
? 'Countries'
: 'Ratings'
);
const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
</script>
@@ -381,6 +451,36 @@
</div>
{/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}
@@ -392,7 +492,7 @@
title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText
? undefined
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
: '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">
@@ -448,6 +548,69 @@
{/if}
</div>
{/if}
{:else if category === 'countries'}
{#if countriesQuery.isPending}
<InlineLoader size="sm" label="Loading countries…" />
{:else if countriesQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load countries" />
{:else if filteredCountries.length === 0}
<EmptyState
size="compact"
icon={Globe}
title={filterText ? 'No countries match the filter' : 'No geotagged photos yet'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleCountries as countryRow (countryRow.Code)}
{@const active = countryRow.Code === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickCountry(countryRow.Code)}
title={countryName(countryRow.Code)}
>
{#if countryRow.Thumb}
<img
src={thumbUrl(countryRow.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded object-cover"
/>
{:else}
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-[14px]">
{countryFlag(countryRow.Code)}
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{countryName(countryRow.Code)}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{countryRow.PhotoCount}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreCountries,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreCountries}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredCountries.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>

View File

@@ -33,8 +33,7 @@
doneBulk,
removedBulk,
failBulk,
markRemoved,
clearRemoved
markRemoved
} from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
@@ -156,15 +155,14 @@
throw e;
} finally {
busy = false;
const settled = Promise.all([
qc.invalidateQueries({ queryKey: ['photos'] }),
qc.invalidateQueries({ queryKey: ['marks'] }),
qc.invalidateQueries({ queryKey: ['review-groups'] })
]);
// Clear the optimistic-removal overlay only once the refetch has
// landed, so tiles never flash back in before the fresh (archived-
// filtered) page replaces the old one.
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
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.
}
}

View File

@@ -17,8 +17,9 @@
import { view } from "$lib/stores/view.svelte";
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, X } from "lucide-svelte";
import { Loader2, Check, Heart, X } from "lucide-svelte";
interface Props {
photo: PpPhoto;
@@ -191,4 +192,28 @@
>
{/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>
</div>

View File

@@ -9,7 +9,12 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
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;
}
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
export async function listDuplicateGroups(basePath?: string): Promise<DuplicateGroup[]> {
// Build query: stack:true + optional path filter
const pathFilter = basePath ? ` path:${basePath}*` : '';
const q = `stack:true${pathFilter}`;
const photos = await listPhotos({
q: 'stack:true',
count: 200,
q,
count: 500,
merged: true,
order: 'newest'
});

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

@@ -19,6 +19,8 @@ import {
batchArchive,
batchRestore,
buildTakenAtPatch,
likePhoto,
unlikePhoto,
updatePhoto
} from './photoprism';
import { queryClient } from '$lib/queryClient';
@@ -102,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
originalName: p.OriginalName,
path
});
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`, p));
}
await approvePhoto(id);
return id;
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
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)));
});
}
/**
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
*/

View File

@@ -7,7 +7,8 @@ import {
session,
toOriginalsPath,
toUserPath,
userBasePath
userBasePath,
userLibraryBase
} from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism';
import type {
@@ -342,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
*/
export interface UpdatePhotoBody {
OriginalName?: string;
Title?: string;
TitleSrc?: 'manual' | '';
Caption?: string;
CaptionSrc?: 'manual' | '';
Archived?: boolean;
@@ -372,17 +375,43 @@ export function isValidISODate(s: string): boolean {
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);
if (Number.isNaN(d.getTime())) return {};
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 {
TakenAt: utc,
TakenAtLocal: utc,
TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
TakenSrc: 'manual',
Year: d.getUTCFullYear(),
Month: d.getUTCMonth() + 1,
Day: d.getUTCDate()
// PhotoPrism derives Year/Month/Day from local wall-clock time.
Year: local.getUTCFullYear(),
Month: local.getUTCMonth() + 1,
Day: local.getUTCDate()
};
}
@@ -503,23 +532,45 @@ export interface PpFolder {
* row itself is dropped — the sidebar synthesises the root entry. When
* BasePath is empty (today's admin default) this is a no-op.
*/
export async function listFolders(): Promise<PpFolder[]> {
async function fetchFolders(): Promise<PpFolder[]> {
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } }
);
const bp = userBasePath();
// Sidecar already filters by BasePath; the frontend still applies the
// filter + path rewrite as a safety net for admin (bp="") and for any
// folders that might have slipped through.
const folders = data.folders ?? [];
if (bp === '') return folders;
return data.folders ?? [];
}
/**
* Filter a flat folder list to those at/under `base` (server-absolute,
* originals-relative) and rewrite each `Path` to be `base`-relative, dropping
* the `base` row itself. `base === ''` (whole library) is a no-op. Sidecar
* already filters by BasePath; this is the frontend's safety net + the
* narrowing to the chosen index sub-path.
*/
function scopeFolders(folders: PpFolder[], base: string): PpFolder[] {
if (base === '') return folders;
return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
.filter((f) => f.Path === base || f.Path.startsWith(base + '/'))
.map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) }))
.filter((f) => f.Path !== '');
}
export async function listFolders(): Promise<PpFolder[]> {
// Scoped to the *effective* library root (BasePath + chosen index
// sub-path) so the sidebar tree re-roots to whatever the user picked.
return scopeFolders(await fetchFolders(), userLibraryBase());
}
/**
* Like `listFolders` but scoped to the user's *whole* BasePath, ignoring the
* chosen index sub-path. The index-folder picker uses this so the user can
* choose any sub-folder of their library as a new root — including ones
* outside the current sub-path.
*/
export async function listFoldersUnderBase(): Promise<PpFolder[]> {
return scopeFolders(await fetchFolders(), userBasePath());
}
/**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, so the count has
@@ -560,35 +611,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
return out;
}
// ── Geo ──────────────────────────────────────────────────────────────────────
// ── Countries ────────────────────────────────────────────────────────────────
export interface PpGeoFeature {
type: 'Feature';
id: string;
geometry: { type: 'Point'; coordinates: [number, number] };
properties: {
UID: string;
Hash: string;
Title?: string;
TakenAt?: string;
FavId?: number;
};
export interface PpCountry {
Code: string;
PhotoCount: number;
Thumb?: string;
}
export interface PpGeoCollection {
type: 'FeatureCollection';
features: PpGeoFeature[];
bbox?: number[];
}
export async function listGeo(q = ''): Promise<PpGeoCollection> {
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
// matching geocoded photo. MapLibre's native clustering handles 50k+
// points without breaking a sweat (PhotoPrism upstream documents
// 500k); we ask for a generous cap that covers realistic libraries.
const { data } = await http.get<PpGeoCollection>('/geo', {
params: { count: 50000, q: q || undefined }
});
export async function listCountries(): Promise<PpCountry[]> {
// Self-contained sidecar aggregation (groups photos.photo_country directly,
// no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the
// caller's BasePath the same way /labels and /counts are.
const { data } = await sidecar.get<PpCountry[]>('/api/sidecar/countries');
return data;
}
@@ -687,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
}
async function hasPhotosMatching(q: string): Promise<boolean> {
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 1, offset: 0, q }
});
return Array.isArray(resp.data) && resp.data.length > 0;
}
async function filterByUserPhotos<T>(
items: T[],
queryFor: (item: T) => string
): Promise<T[]> {
if (userBasePath() === '') return items;
const CONCURRENCY = 8;
const out: T[] = [];
for (let i = 0; i < items.length; i += CONCURRENCY) {
const batch = items.slice(i, i + CONCURRENCY);
const checks = await Promise.all(
batch.map(async (item) => ({
item,
has: await hasPhotosMatching(queryFor(item))
}))
);
for (const { item, has } of checks) {
if (has) out.push(item);
}
}
return out;
}
export async function listLabels(): Promise<PpLabel[]> {
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
// low-confidence classifier hits, manually-removed labels). They're
@@ -755,10 +761,42 @@ export interface PpSubject {
}
export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', {
// 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 filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
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> {
@@ -942,6 +980,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
export interface DupFileEntry {
path: string;
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 {
@@ -974,6 +1015,21 @@ export async function archiveDuplicatePaths(
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) ────────────────────────
// Lives on the sidecar because moving the underlying files is a filesystem
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
@@ -995,6 +1051,8 @@ export interface HeapConvertBody {
export interface HeapConvertResult {
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 }[];
heap_deleted: boolean;
}
@@ -1022,6 +1080,8 @@ export interface PhotosMoveBody {
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 }[];
}
@@ -1029,6 +1089,21 @@ export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMo
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 {
@@ -1043,6 +1118,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise<Fol
}) 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) ─────────────────────────────────────────────
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
// internal fields). We store them in mule-sidecar instead.
@@ -1093,26 +1180,17 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
// ── Settings / Admin ─────────────────────────────────────────────────────────
//
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer
// (Library / Index / Logs). Shapes are deliberately partial — newer
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint
// merges server-side, so it's safe to round-trip an incomplete object.
// PhotoPrism's /settings payload. muleimage only drives the indexer/stack/
// download knobs from its own UI — the `ui`/`search`/`maps`/`import`/`features`
// blocks PhotoPrism also returns only steer PhotoPrism's own SPA (which our
// users never see), so they're intentionally omitted here and never surfaced.
// The `[k: string]` index signature means an unknown round-tripped block is
// preserved on save without us having to model it.
export interface PpSettings {
ui?: {
theme?: string;
language?: string;
timeZone?: string;
startPage?: string;
scrollbar?: boolean;
zoom?: boolean;
};
search?: {
batchSize?: number;
listView?: boolean;
showTitles?: boolean;
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: {
path?: string;
convert?: boolean;
@@ -1122,7 +1200,6 @@ export interface PpSettings {
skipRaw?: boolean;
skipHidden?: boolean;
};
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
name?: string;
@@ -1133,42 +1210,27 @@ export interface PpSettings {
crc32?: boolean;
sha1?: boolean;
};
/**
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
* matching API endpoints) inside PP's own SPA — disabling `share` for
* example hides every share button. Optional because older PP versions
* don't return the block; the Library tab only renders toggles for
* keys it actually sees in the response.
*/
features?: {
archive?: boolean;
private?: boolean;
review?: boolean;
files?: boolean;
folders?: boolean;
moments?: boolean;
calendar?: boolean;
places?: boolean;
edit?: boolean;
share?: boolean;
library?: boolean;
import?: boolean;
logs?: boolean;
search?: boolean;
account?: boolean;
settings?: boolean;
services?: boolean;
people?: boolean;
labels?: boolean;
download?: boolean;
upload?: boolean;
delete?: boolean;
ratings?: boolean;
[k: string]: boolean | undefined;
};
[k: string]: unknown;
}
// ── Per-user prefs (sidecar) ──────────────────────────────────────────────────
//
// The index sub-path: an originals-relative folder under the user's BasePath
// that re-roots the Library tree and scopes the reindex. Stored server-side by
// the sidecar, keyed by username. Empty string = "whole folder".
export async function getIndexSubpath(): Promise<string> {
const data = (await callSidecar('GET', '/prefs')) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function setIndexSubpath(indexPath: string): Promise<string> {
const data = (await callSidecar('PUT', '/prefs', {
indexPath: indexPath.replace(/^\/+|\/+$/g, '')
})) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings');
return data;
@@ -1199,26 +1261,6 @@ export async function cancelIndex(): Promise<void> {
await http.delete('/index');
}
export interface ImportBody {
path?: string;
move?: boolean;
dest?: string;
}
export async function startImport(body: ImportBody = {}): Promise<{ message: string }> {
const { data } = await http.post<{ message: string }>('/import', {
path: '/',
move: false,
dest: '',
...body
});
return data;
}
export async function cancelImport(): Promise<void> {
await http.delete('/import');
}
export interface PpLogEntry {
Time: string;
Level: string;

View File

@@ -17,14 +17,21 @@ export type Section =
| 'hidden'
| 'heap';
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
export type TagCategory =
| 'labels'
| 'keywords'
| 'people'
| 'colors'
| 'ratings'
| 'countries';
export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels',
'keywords',
'people',
'colors',
'ratings'
'ratings',
'countries'
] as const;
export function isTagCategory(v: unknown): v is TagCategory {
@@ -34,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 {
section: Section;
/** Heap UID, used when section === 'heap'. */
@@ -42,6 +68,14 @@ export interface FilterState {
folderPath: string | null;
/** Free-form search text, ANDed with section-derived terms. */
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
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
@@ -61,10 +95,42 @@ export const filters = $state<FilterState>({
heapUid: null,
folderPath: '/',
search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: 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 {
filters.section = section;
filters.heapUid = section === 'heap' ? heapUid : null;
@@ -239,9 +305,23 @@ export function filtersToQ(f: FilterState = filters): string {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'people') {
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'countries') {
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
}
}
if (f.search) parts.push(quoteIfNeeded(f.search));
// 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(' ');
}
@@ -263,11 +343,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
!params.has('q');
const folderRaw = params.get('folder');
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
const sortRaw = params.get('sort');
const typeRaw = params.get('type');
const yearRaw = params.get('year');
return {
section,
heapUid: params.get('heap'),
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'
};
}
@@ -279,5 +370,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
if (f.heapUid) params.set('heap', f.heapUid);
if (f.folderPath) params.set('folder', f.folderPath);
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;
}

View File

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

View File

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

View File

@@ -81,6 +81,10 @@ export const view = $state<{
* persisted — a refresh always returns to the grid.
*/
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>;
}>({
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
@@ -107,6 +111,8 @@ export const view = $state<{
),
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
previewOpen: false,
shortcutsOpen: false,
paletteOpen: false,
metadataSections:
initial.metadataSections && typeof initial.metadataSections === 'object'
? { ...initial.metadataSections }
@@ -164,6 +170,22 @@ export function togglePreview(): void {
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 {
view.thumbnailSize = size;
persist();

View File

@@ -130,6 +130,7 @@ export interface PpPhoto {
Height?: number;
Rating?: number;
Color?: string | number;
Favorite?: boolean;
Archived?: boolean;
Files?: PpFile[];
Lat?: number;
@@ -256,10 +257,17 @@ export function isVideo(p: PpPhoto): boolean {
/** Return the Files[] entry that carries the actual video stream. Falls back
* to primaryFile() if no video MediaType is present (shouldn't happen for
* Type === 'video' but keeps the call site total). */
* 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 {
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);
}
@@ -275,6 +283,34 @@ export interface PpFile {
Size?: number;
FileType?: 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 =

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

@@ -20,6 +20,9 @@
import AnimatedMule from '$lib/components/mule/AnimatedMule.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();
@@ -58,8 +61,18 @@
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>
<svelte:window onkeydown={onGlobalKey} />
<svelte:head>
<title>Mulimage</title>
</svelte:head>
@@ -122,6 +135,10 @@
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}
{@render children?.()}
{/if}

View File

@@ -17,14 +17,26 @@
type PpAlbum,
} from "$lib/services/photoprism";
import {
chipsActive,
clearChips,
consumePendingFocus,
filters,
filtersToQ,
filtersToUrlParams,
MEDIA_TYPE_LABELS,
MEDIA_TYPES,
parseUrlParams,
setFavorite,
setMediaType,
setSearch,
setSection,
setSort,
setYear,
SORT_LABELS,
SORT_ORDERS,
type MediaType,
type PendingFocus,
type SortOrder,
} from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte";
@@ -37,7 +49,7 @@
setFocused,
setOrder,
} from "$lib/stores/selection.svelte";
import { removedIds } from "$lib/stores/bulkAction.svelte";
import { removedIds, clearRemoved } from "$lib/stores/bulkAction.svelte";
import {
openPreview,
setRightSidebarWidth,
@@ -81,6 +93,10 @@
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
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
@@ -182,15 +198,20 @@
"photos",
"q",
filtersToQ(filters),
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
{
count: PHOTOS_PAGE_SIZE,
anchor: anchor?.takenAt ?? null,
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).
if (offset === 0 && anchor?.takenAt) {
// 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,
@@ -203,7 +224,7 @@
q: baseQ,
count: PHOTOS_PAGE_SIZE,
offset,
order: "newest",
order: filters.sort,
merged: true,
});
},
@@ -222,7 +243,7 @@
// photos exceeding the page size keep the cursor at the same
// value). To "see more," the user clears the anchor by
// navigating fresh.
if (anchor?.takenAt) return undefined;
if (anchor?.takenAt && filters.sort === "newest") return undefined;
return pages.length * PHOTOS_PAGE_SIZE;
},
enabled: isAuthenticated(),
@@ -283,6 +304,20 @@
}
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
// Reconcile the optimistic-removal overlay against the actual cache.
// `removedIds` hides a tile while its photo is still present in a loaded
// page; we drop an id from the set only once it has genuinely left the
// freshly-deduped cache (i.e. every page that held it has refetched
// without it). Driving the clear from the data — rather than from each
// archive action's invalidation promise — removes the race where settling
// one action's refetch un-hid a photo that other, still-stale pages
// continued to carry, making archived tiles flash back into the grid.
$effect(() => {
const present = new Set(dedupedAll.map((p) => p.UID));
const gone = [...removedIds].filter((id) => !present.has(id));
if (gone.length) clearRemoved(gone);
});
$effect(() => {
setOrder(photos.map((p) => p.UID));
});
@@ -786,6 +821,16 @@
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
// focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [
@@ -829,6 +874,7 @@
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input
type="search"
data-search-input
placeholder={'Search · label:website / "vacation"'}
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft}
@@ -884,6 +930,68 @@
{/if}
</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()}
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
Persisted to localStorage via view.svelte.ts. -->

View File

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

@@ -30,7 +30,7 @@
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import {
@@ -85,13 +85,16 @@
// observes its cache (enabled:false) and DuplicatesView is what
// triggers the actual scan when its tab is active.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
queryKey: ['duplicates', userLibraryBase()],
queryFn: () => listDuplicateGroups(userLibraryBase()),
enabled: isAuthenticated(),
staleTime: 30_000
}));
// Scope is enforced server-side (sidecar reads the caller's BasePath +
// stored index sub-path), but key on userLibraryBase() so switching the
// index folder doesn't show a stale, differently-scoped cached result.
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryKey: ['duplicates-cross-folder', userLibraryBase()],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000

View File

@@ -29,8 +29,10 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
@@ -51,6 +53,16 @@
: 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
// `filters` (e.g. cross-route navigation back to `/`) sees the active
// tag filter, and so `filtersToQ()` produces the correct DSL clause
@@ -69,7 +81,10 @@
// label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them).
const useServer = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
const drillQ = $derived(
useServer && selectedValue
@@ -78,6 +93,10 @@
heapUid: null,
folderPath: null,
search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: category,
tagValue: selectedValue
})
@@ -161,6 +180,7 @@
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
);
}
if (category === 'countries') return countryName(selectedValue);
return selectedValue;
});
@@ -198,7 +218,7 @@
{#if category}
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
{/if}
{#if selectedValue}
{#if selectedValue && !showNewFaces}
<span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'}
@@ -206,7 +226,23 @@
{/if}
</Toolbar>
{#if !selectedValue}
{#if category === 'people' && (showNewFaces || !selectedValue)}
<!-- Naming workflow: reachable both on bare landing (no person picked
yet) and via the sidebar's pinned "Name new faces" row at any time
— the latter is what makes it possible to get back here after the
first person's been named, once the auto-select-first-tag effect
would otherwise always jump straight to an existing person. -->
<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>
</main>
{:else if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState
icon={Tag}